From 0a8130858c347b4f0b9ebce9c032dbe9d8139ee4 Mon Sep 17 00:00:00 2001 From: Angel Nunez Mencias Date: Wed, 8 Apr 2026 23:57:53 +0100 Subject: [PATCH 001/575] [ade7953_spi] Fix SPI mode on esp-idf (#14824) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../components/ade7953_base/ade7953_base.cpp | 10 ++++++- .../components/ade7953_base/ade7953_base.h | 30 +++++++++++-------- .../components/ade7953_spi/ade7953_spi.cpp | 6 ++++ esphome/components/ade7953_spi/ade7953_spi.h | 2 +- 4 files changed, 33 insertions(+), 15 deletions(-) diff --git a/esphome/components/ade7953_base/ade7953_base.cpp b/esphome/components/ade7953_base/ade7953_base.cpp index 821e4a3105..2dfab8ff85 100644 --- a/esphome/components/ade7953_base/ade7953_base.cpp +++ b/esphome/components/ade7953_base/ade7953_base.cpp @@ -8,6 +8,9 @@ namespace ade7953_base { static const char *const TAG = "ade7953"; +constexpr uint16_t CONFIG_DEFAULT = 0x8004u; +constexpr uint16_t CONFIG_LOCK_BIT = 0x8000u; + static const float ADE_POWER_FACTOR = 154.0f; static const float ADE_WATTSEC_POWER_FACTOR = ADE_POWER_FACTOR * ADE_POWER_FACTOR / 3600; @@ -18,7 +21,12 @@ void ADE7953::setup() { // The chip might take up to 100ms to initialise this->set_timeout(100, [this]() { - // this->ade_write_8(0x0010, 0x04); + // Lock communication interface (SPI or I2C) + uint16_t config_v = CONFIG_DEFAULT; + this->ade_read_16(CONFIG_16, &config_v); + config_v &= static_cast(~CONFIG_LOCK_BIT); // Clear the lock bit + this->ade_write_16(CONFIG_16, config_v); + // Configure optimum settings according to datasheet this->ade_write_8(0x00FE, 0xAD); this->ade_write_16(0x0120, 0x0030); // Set gains diff --git a/esphome/components/ade7953_base/ade7953_base.h b/esphome/components/ade7953_base/ade7953_base.h index bcafddca4e..b58f95b230 100644 --- a/esphome/components/ade7953_base/ade7953_base.h +++ b/esphome/components/ade7953_base/ade7953_base.h @@ -9,31 +9,35 @@ namespace esphome { namespace ade7953_base { -static const uint8_t PGA_V_8 = +static constexpr uint8_t PGA_V_8 = 0x007; // PGA_V, (R/W) Default: 0x00, Unsigned, Voltage channel gain configuration (Bits[2:0]) -static const uint8_t PGA_IA_8 = +static constexpr uint8_t PGA_IA_8 = 0x008; // PGA_IA, (R/W) Default: 0x00, Unsigned, Current Channel A gain configuration (Bits[2:0]) -static const uint8_t PGA_IB_8 = +static constexpr uint8_t PGA_IB_8 = 0x009; // PGA_IB, (R/W) Default: 0x00, Unsigned, Current Channel B gain configuration (Bits[2:0]) -static const uint32_t AIGAIN_32 = +static constexpr uint16_t CONFIG_16 = 0x102; // CONFIG, (R/W) Default: 0x8004, Unsigned, Configuration register + +static constexpr uint16_t AIGAIN_32 = 0x380; // AIGAIN, (R/W) Default: 0x400000, Unsigned,Current channel gain (Current Channel A)(32 bit) -static const uint32_t AVGAIN_32 = 0x381; // AVGAIN, (R/W) Default: 0x400000, Unsigned,Voltage channel gain(32 bit) -static const uint32_t AWGAIN_32 = +static constexpr uint16_t AVGAIN_32 = + 0x381; // AVGAIN, (R/W) Default: 0x400000, Unsigned,Voltage channel gain(32 bit) +static constexpr uint16_t AWGAIN_32 = 0x382; // AWGAIN, (R/W) Default: 0x400000, Unsigned,Active power gain (Current Channel A)(32 bit) -static const uint32_t AVARGAIN_32 = +static constexpr uint16_t AVARGAIN_32 = 0x383; // AVARGAIN, (R/W) Default: 0x400000, Unsigned, Reactive power gain (Current Channel A)(32 bit) -static const uint32_t AVAGAIN_32 = +static constexpr uint16_t AVAGAIN_32 = 0x384; // AVAGAIN, (R/W) Default: 0x400000, Unsigned,Apparent power gain (Current Channel A)(32 bit) -static const uint32_t BIGAIN_32 = +static constexpr uint16_t BIGAIN_32 = 0x38C; // BIGAIN, (R/W) Default: 0x400000, Unsigned,Current channel gain (Current Channel B)(32 bit) -static const uint32_t BVGAIN_32 = 0x38D; // BVGAIN, (R/W) Default: 0x400000, Unsigned,Voltage channel gain(32 bit) -static const uint32_t BWGAIN_32 = +static constexpr uint16_t BVGAIN_32 = + 0x38D; // BVGAIN, (R/W) Default: 0x400000, Unsigned,Voltage channel gain(32 bit) +static constexpr uint16_t BWGAIN_32 = 0x38E; // BWGAIN, (R/W) Default: 0x400000, Unsigned,Active power gain (Current Channel B)(32 bit) -static const uint32_t BVARGAIN_32 = +static constexpr uint16_t BVARGAIN_32 = 0x38F; // BVARGAIN, (R/W) Default: 0x400000, Unsigned, Reactive power gain (Current Channel B)(32 bit) -static const uint32_t BVAGAIN_32 = +static constexpr uint16_t BVAGAIN_32 = 0x390; // BVAGAIN, (R/W) Default: 0x400000, Unsigned,Apparent power gain (Current Channel B)(32 bit) class ADE7953 : public PollingComponent, public sensor::Sensor { diff --git a/esphome/components/ade7953_spi/ade7953_spi.cpp b/esphome/components/ade7953_spi/ade7953_spi.cpp index 6b16d933a2..a69b7d19fb 100644 --- a/esphome/components/ade7953_spi/ade7953_spi.cpp +++ b/esphome/components/ade7953_spi/ade7953_spi.cpp @@ -7,6 +7,9 @@ namespace ade7953_spi { static const char *const TAG = "ade7953"; +// Datasheet requires at least 1.2µs after clearing CONFIG LOCK_BIT before raising CS +constexpr uint8_t CONFIG_LOCK_SETTLE_US = 2; + void AdE7953Spi::setup() { this->spi_setup(); ade7953_base::ADE7953::setup(); @@ -32,6 +35,9 @@ bool AdE7953Spi::ade_write_16(uint16_t reg, uint16_t value) { this->write_byte16(reg); this->transfer_byte(0); this->write_byte16(value); + if (reg == ade7953_base::CONFIG_16) { + delayMicroseconds(CONFIG_LOCK_SETTLE_US); + } this->disable(); return false; } diff --git a/esphome/components/ade7953_spi/ade7953_spi.h b/esphome/components/ade7953_spi/ade7953_spi.h index d96852b9bb..27f6025d98 100644 --- a/esphome/components/ade7953_spi/ade7953_spi.h +++ b/esphome/components/ade7953_spi/ade7953_spi.h @@ -12,7 +12,7 @@ namespace esphome { namespace ade7953_spi { class AdE7953Spi : public ade7953_base::ADE7953, - public spi::SPIDevice { public: void setup() override; From 76490e45bc3c164d4ffe5111a7f61b8af1f66b90 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2026 13:08:29 -1000 Subject: [PATCH 002/575] [ci] Fix status-check-labels workflow flooding CI queue (#15585) --- .github/workflows/status-check-labels.yml | 25 +++++++++++------------ 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/.github/workflows/status-check-labels.yml b/.github/workflows/status-check-labels.yml index cca70815b9..6483bbe789 100644 --- a/.github/workflows/status-check-labels.yml +++ b/.github/workflows/status-check-labels.yml @@ -2,30 +2,29 @@ name: Status check labels on: pull_request: - types: [labeled, unlabeled] + types: [opened, reopened, labeled, unlabeled, synchronize] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true jobs: check: - name: Check ${{ matrix.label }} + name: Check blocking labels runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - label: - - needs-docs - - merge-after-release - - chained-pr steps: - - name: Check for ${{ matrix.label }} label + - name: Check for blocking labels uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: | + const blockingLabels = ['needs-docs', 'merge-after-release', 'chained-pr']; const { data: labels } = await github.rest.issues.listLabelsOnIssue({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number }); - const hasLabel = labels.find(label => label.name === '${{ matrix.label }}'); - if (hasLabel) { - core.setFailed('Pull request cannot be merged, it is labeled as ${{ matrix.label }}'); + const labelNames = labels.map(l => l.name); + const found = blockingLabels.filter(bl => labelNames.includes(bl)); + if (found.length > 0) { + core.setFailed(`Pull request cannot be merged, it has blocking label(s): ${found.join(', ')}`); } From 52c35ec09cc9982a8c9adf31f2cd63c95611f9aa Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 9 Apr 2026 10:39:14 +1200 Subject: [PATCH 003/575] Bump version to 2026.5.0-dev --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index cfdb74bd19..7fce941c9b 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-dev +PROJECT_NUMBER = 2026.5.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 29ce030329..c2bf86d532 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-dev" +__version__ = "2026.5.0-dev" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( From abdbbf4dd27699abc35efb647f593e3ae191077e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2026 16:14:01 -1000 Subject: [PATCH 004/575] [api] Fix ListEntitiesRequest not read due to LWIP rcvevent tracking (#15589) --- esphome/components/api/api_connection.cpp | 36 +++++++++++++++-------- esphome/components/api/api_connection.h | 1 + 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 7db423141c..4663456da6 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -52,11 +52,11 @@ 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. -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 46d0c29be5fdec7d4a03c3252a53b7c1651c2f67 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2026 19:20:32 -1000 Subject: [PATCH 005/575] [safe_mode] Use loop component start time instead of millis() (#15591) --- 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 eafc5df3f29d6e4d895d8947db95bd77a891506f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 8 Apr 2026 19:30:39 -1000 Subject: [PATCH 006/575] [safe_mode] Combine related OTA rollback log messages (#15592) --- 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 5b0f7c8827..bae5e42b9b 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 2721f08bcc5e0c0238dd4a77bda50fe5d7fc7676 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 10:03:58 +0000 Subject: [PATCH 007/575] Bump aioesphomeapi from 44.12.0 to 44.13.1 (#15600) 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 c4b90b5ca9..d7db44454c 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.12.0 +aioesphomeapi==44.13.1 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 6f5d642a310623bddf175eaaf3bbc99084e263ee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Apr 2026 05:48:10 -1000 Subject: [PATCH 008/575] [gdk101] Increase reset retries for slow-booting sensor MCU (#15584) --- esphome/components/gdk101/gdk101.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esphome/components/gdk101/gdk101.cpp b/esphome/components/gdk101/gdk101.cpp index 149973ba8a..0ee718cd20 100644 --- a/esphome/components/gdk101/gdk101.cpp +++ b/esphome/components/gdk101/gdk101.cpp @@ -7,7 +7,7 @@ namespace gdk101 { static const char *const TAG = "gdk101"; static constexpr uint8_t NUMBER_OF_READ_RETRIES = 5; -static constexpr uint8_t NUMBER_OF_RESET_RETRIES = 10; +static constexpr uint8_t NUMBER_OF_RESET_RETRIES = 30; static constexpr uint32_t RESET_INTERVAL_ID = 0; static constexpr uint32_t RESET_INTERVAL_MS = 1000; From dd07fba943f27a9105ede153217bb62d4910d58c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Apr 2026 05:48:18 -1000 Subject: [PATCH 009/575] [socket] Document ready() contract: callers must drain or track (#15590) --- esphome/components/api/api_frame_helper.h | 5 ++++- esphome/components/socket/bsd_sockets_impl.h | 2 ++ esphome/components/socket/lwip_raw_tcp_impl.h | 2 ++ esphome/components/socket/lwip_sockets_impl.h | 2 ++ esphome/components/socket/socket.h | 13 +++++++++++++ 5 files changed, 23 insertions(+), 1 deletion(-) diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index d1215388d2..f98eca8076 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -195,7 +195,10 @@ class APIFrameHelper { } // Get the frame footer size required by this protocol uint8_t frame_footer_size() const { return frame_footer_size_; } - // Check if socket has data ready to read + // Check if socket has buffered data ready to read. + // Contract: callers must read until it would block (EAGAIN/EWOULDBLOCK) + // 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(); } // Release excess memory from internal buffers after initial sync void release_buffers() { diff --git a/esphome/components/socket/bsd_sockets_impl.h b/esphome/components/socket/bsd_sockets_impl.h index 339a699bc9..e520784702 100644 --- a/esphome/components/socket/bsd_sockets_impl.h +++ b/esphome/components/socket/bsd_sockets_impl.h @@ -112,6 +112,8 @@ class BSDSocketImpl { int setblocking(bool blocking); int loop() { return 0; } + /// Check if the socket has buffered data ready to read. + /// See the ready() contract in socket.h — callers must drain or track remaining data. bool ready() const; int get_fd() const { return this->fd_; } diff --git a/esphome/components/socket/lwip_raw_tcp_impl.h b/esphome/components/socket/lwip_raw_tcp_impl.h index e2dcb80d32..917b5b2f7a 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.h +++ b/esphome/components/socket/lwip_raw_tcp_impl.h @@ -96,6 +96,8 @@ class LWIPRawImpl : public LWIPRawCommon { errno = ENOSYS; return -1; } + // Check if the socket has buffered data ready to read. + // See the ready() contract in socket.h — callers must drain or track remaining data. // Intentionally unlocked — this is a polling check called every loop iteration. // A stale read at worst delays processing by one loop tick; the actual I/O in // read() holds the lwip lock and re-checks properly. See esphome#10681. diff --git a/esphome/components/socket/lwip_sockets_impl.h b/esphome/components/socket/lwip_sockets_impl.h index bfc4da9926..942d0ccf85 100644 --- a/esphome/components/socket/lwip_sockets_impl.h +++ b/esphome/components/socket/lwip_sockets_impl.h @@ -78,6 +78,8 @@ class LwIPSocketImpl { int setblocking(bool blocking); int loop() { return 0; } + /// Check if the socket has buffered data ready to read. + /// See the ready() contract in socket.h — callers must drain or track remaining data. bool ready() const; int get_fd() const { return this->fd_; } diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index 9ea71321e0..ad55e889e8 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -53,6 +53,19 @@ bool socket_ready_fd(int fd, bool loop_monitored); // Inline ready() — defined here because it depends on socket_ready/socket_ready_fd // declared above, while the impl headers are included before those declarations. +// +// Contract (applies to ALL socket implementations — each platform implements +// ready() differently, but this contract holds regardless of the mechanism): +// ready() checks if the socket has buffered data ready to read. When it returns +// true, the caller MUST read until it would block (EAGAIN/EWOULDBLOCK), or until +// read() returns 0 to indicate EOF / connection closed, or track that it stopped +// early and retry without calling ready(). The next call to ready() will only +// report new data correctly if all callers fulfill this contract. Failing to +// drain the socket may cause ready() to return false while data remains readable. +// +// In practice each socket is owned by a single component, so this contract is +// straightforward to fulfill — but the owning component must be aware of it, +// especially if it limits how many messages it processes per loop iteration. #if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) inline bool Socket::ready() const { #ifdef USE_LWIP_FAST_SELECT From 8f6d489a9a0bb4e5b35ee387446e1fb5f0610526 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Apr 2026 05:48:33 -1000 Subject: [PATCH 010/575] [ci] Use --base-only for memory impact builds (#15598) --- .github/workflows/ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dddf21f57e..cf9fa8e7c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -868,7 +868,8 @@ jobs: python script/test_build_components.py \ -e compile \ -c "$component_list" \ - -t "$platform" 2>&1 | \ + -t "$platform" \ + --base-only 2>&1 | \ tee /dev/stderr | \ python script/ci_memory_impact_extract.py \ --output-env \ @@ -954,7 +955,8 @@ jobs: python script/test_build_components.py \ -e compile \ -c "$component_list" \ - -t "$platform" 2>&1 | \ + -t "$platform" \ + --base-only 2>&1 | \ tee /dev/stderr | \ python script/ci_memory_impact_extract.py \ --output-env \ From 03db32d045081b6716ebd4260be9ba6cebbfa08c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Apr 2026 07:48:32 -1000 Subject: [PATCH 011/575] [core] Add CodSpeed benchmarks for hot helper functions (#15593) --- tests/benchmarks/core/bench_helpers.cpp | 271 +++++++++++++++++++++++- 1 file changed, 270 insertions(+), 1 deletion(-) diff --git a/tests/benchmarks/core/bench_helpers.cpp b/tests/benchmarks/core/bench_helpers.cpp index c6e1e6930e..d9a9d158a3 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,274 @@ 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) { + char name[] = "Living Room Temperature Sensor"; + size_t len = sizeof(name) - 1; + benchmark::DoNotOptimize(name); + for (auto _ : state) { + uint32_t result = 0; + for (int i = 0; i < kInnerIterations; i++) { + result ^= fnv1_hash_object_id(name, len); + benchmark::ClobberMemory(); + } + 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); + benchmark::ClobberMemory(); + } + } + 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 d062f62656a3632384df7c81f70afe845d28aeab Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Thu, 9 Apr 2026 15:00:52 -0400 Subject: [PATCH 012/575] [sx127x][cc1101] Disable loop when packet mode is inactive (#15606) --- esphome/components/cc1101/cc1101.cpp | 9 +++++++++ esphome/components/sx127x/sx127x.cpp | 7 ++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/esphome/components/cc1101/cc1101.cpp b/esphome/components/cc1101/cc1101.cpp index 51aa88b8f7..f2b7451721 100644 --- a/esphome/components/cc1101/cc1101.cpp +++ b/esphome/components/cc1101/cc1101.cpp @@ -150,6 +150,10 @@ void CC1101Component::setup() { if (this->gdo0_pin_ != nullptr) { this->defer([this]() { this->gdo0_pin_->pin_mode(gpio::FLAG_INPUT); }); } + + if (this->state_.PKT_FORMAT != static_cast(PacketFormat::PACKET_FORMAT_FIFO)) { + this->disable_loop(); + } } void CC1101Component::call_listeners_(const std::vector &packet, float freq_offset, float rssi, uint8_t lqi) { @@ -669,6 +673,11 @@ void CC1101Component::set_packet_mode(bool value) { this->state_.GDO0_CFG = 0x0D; } if (this->initialized_) { + if (value) { + this->enable_loop(); + } else { + this->disable_loop(); + } this->write_(Register::PKTCTRL0); this->write_(Register::PKTCTRL1); this->write_(Register::IOCFG0); diff --git a/esphome/components/sx127x/sx127x.cpp b/esphome/components/sx127x/sx127x.cpp index 0fddfdccdb..83be96767a 100644 --- a/esphome/components/sx127x/sx127x.cpp +++ b/esphome/components/sx127x/sx127x.cpp @@ -383,9 +383,14 @@ void SX127x::set_mode_(uint8_t modulation, uint8_t mode) { if (millis() - start > 20) { ESP_LOGE(TAG, "Set mode failure"); this->mark_failed(); - break; + return; } } + if (mode == MODE_RX && (modulation == MOD_LORA || this->packet_mode_)) { + this->enable_loop(); + } else { + this->disable_loop(); + } } void SX127x::set_mode_rx() { From ab71f5276f8a0b454b22f852ed8ed2882e8dbf8d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:36:25 +0000 Subject: [PATCH 013/575] Bump ruff from 0.15.9 to 0.15.10 (#15609) 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 f8c21aad36..ac4f0049f8 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.9 + rev: v0.15.10 hooks: # Run the linter. - id: ruff diff --git a/requirements_test.txt b/requirements_test.txt index eeee3434ce..18d0461e83 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.9 # also change in .pre-commit-config.yaml when updating +ruff==0.15.10 # 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 668007707da498e12fd9904a152761245821f258 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:13:22 +1200 Subject: [PATCH 014/575] [CI] Add org fork detection warning to auto-label PR workflow (#15588) --- .github/scripts/auto-label-pr/constants.js | 1 + .github/scripts/auto-label-pr/detectors.js | 19 +++++++ .github/scripts/auto-label-pr/index.js | 16 ++++-- .github/scripts/auto-label-pr/reviews.js | 62 +++++++++++++++++++++- 4 files changed, 91 insertions(+), 7 deletions(-) diff --git a/.github/scripts/auto-label-pr/constants.js b/.github/scripts/auto-label-pr/constants.js index 1c33772c4c..e02b450bf0 100644 --- a/.github/scripts/auto-label-pr/constants.js +++ b/.github/scripts/auto-label-pr/constants.js @@ -4,6 +4,7 @@ module.exports = { CODEOWNERS_MARKER: '', TOO_BIG_MARKER: '', DEPRECATED_COMPONENT_MARKER: '', + ORG_FORK_MARKER: '', MANAGED_LABELS: [ 'new-component', diff --git a/.github/scripts/auto-label-pr/detectors.js b/.github/scripts/auto-label-pr/detectors.js index fb9dadc6a0..25c0ba49af 100644 --- a/.github/scripts/auto-label-pr/detectors.js +++ b/.github/scripts/auto-label-pr/detectors.js @@ -281,6 +281,24 @@ async function detectDeprecatedComponents(github, context, changedFiles) { return { labels, deprecatedInfo }; } +// Strategy: Detect when maintainers cannot modify the PR branch +function detectMaintainerAccess(context) { + const pr = context.payload.pull_request; + + // Only relevant for cross-repo PRs (forks) + if (!pr.head.repo || pr.head.repo.full_name === pr.base.repo.full_name) { + return null; + } + + if (pr.maintainer_can_modify) { + return null; + } + + const isOrgFork = pr.head.repo.owner.type === 'Organization'; + console.log(`Maintainer cannot modify PR branch (${isOrgFork ? 'org fork: ' + pr.head.repo.owner.login : 'user disabled'})`); + return { isOrgFork, orgName: pr.head.repo.owner.login }; +} + // Strategy: Requirements detection async function detectRequirements(allLabels, prFiles, context) { const labels = new Set(); @@ -329,5 +347,6 @@ module.exports = { detectTests, detectPRTemplateCheckboxes, detectDeprecatedComponents, + detectMaintainerAccess, detectRequirements }; diff --git a/.github/scripts/auto-label-pr/index.js b/.github/scripts/auto-label-pr/index.js index 42588c0bc8..021e91a9ee 100644 --- a/.github/scripts/auto-label-pr/index.js +++ b/.github/scripts/auto-label-pr/index.js @@ -12,9 +12,10 @@ const { detectTests, detectPRTemplateCheckboxes, detectDeprecatedComponents, + detectMaintainerAccess, detectRequirements } = require('./detectors'); -const { handleReviews } = require('./reviews'); +const { handleReviews, handleMaintainerAccessComment } = require('./reviews'); const { applyLabels, removeOldLabels } = require('./labels'); // Fetch API data @@ -114,7 +115,8 @@ module.exports = async ({ github, context }) => { codeOwnerLabels, testLabels, checkboxLabels, - deprecatedResult + deprecatedResult, + maintainerAccess ] = await Promise.all([ detectMergeBranch(context), detectComponentPlatforms(changedFiles, apiData), @@ -127,7 +129,8 @@ module.exports = async ({ github, context }) => { detectCodeOwner(github, context, changedFiles), detectTests(changedFiles), detectPRTemplateCheckboxes(context), - detectDeprecatedComponents(github, context, changedFiles) + detectDeprecatedComponents(github, context, changedFiles), + detectMaintainerAccess(context) ]); // Extract deprecated component info @@ -177,8 +180,11 @@ module.exports = async ({ github, context }) => { console.log('Computed labels:', finalLabels.join(', ')); - // Handle reviews - await handleReviews(github, context, finalLabels, originalLabelCount, deprecatedInfo, prFiles, totalAdditions, totalDeletions, MAX_LABELS, TOO_BIG_THRESHOLD); + // Handle reviews and org fork comment + await Promise.all([ + handleReviews(github, context, finalLabels, originalLabelCount, deprecatedInfo, prFiles, totalAdditions, totalDeletions, MAX_LABELS, TOO_BIG_THRESHOLD), + handleMaintainerAccessComment(github, context, maintainerAccess) + ]); // Apply labels await applyLabels(github, context, finalLabels); diff --git a/.github/scripts/auto-label-pr/reviews.js b/.github/scripts/auto-label-pr/reviews.js index 906e2c456a..7ac136515d 100644 --- a/.github/scripts/auto-label-pr/reviews.js +++ b/.github/scripts/auto-label-pr/reviews.js @@ -2,7 +2,8 @@ const { BOT_COMMENT_MARKER, CODEOWNERS_MARKER, TOO_BIG_MARKER, - DEPRECATED_COMPONENT_MARKER + DEPRECATED_COMPONENT_MARKER, + ORG_FORK_MARKER } = require('./constants'); // Generate review messages @@ -136,6 +137,63 @@ async function handleReviews(github, context, finalLabels, originalLabelCount, d } } +// Handle maintainer access warning comment +async function handleMaintainerAccessComment(github, context, maintainerAccess) { + if (!maintainerAccess) { + return; + } + + const { owner, repo } = context.repo; + const pr_number = context.issue.number; + const prAuthor = context.payload.pull_request.user.login; + + // Check if we already posted the warning (iterate pages to exit early) + let existingComment; + for await (const { data: comments } of github.paginate.iterator( + github.rest.issues.listComments, + { owner, repo, issue_number: pr_number } + )) { + existingComment = comments.find(comment => + comment.user.type === 'Bot' && + comment.body && comment.body.includes(ORG_FORK_MARKER) + ); + if (existingComment) { + break; + } + } + + if (existingComment) { + console.log('Maintainer access warning comment already exists, skipping'); + return; + } + + let body; + if (maintainerAccess.isOrgFork) { + body = `${ORG_FORK_MARKER}\n### ⚠️ Organization Fork Detected\n\n` + + `Hey there @${prAuthor},\n` + + `It looks like this PR was submitted from a fork owned by the **${maintainerAccess.orgName}** organization. ` + + `GitHub does not allow maintainers to push changes to pull request branches when the fork is owned by an organization. ` + + `This means we won't be able to make small adjustments or fixups to your PR directly.\n\n` + + `To allow maintainer collaboration, please re-submit this PR from a personal fork instead.\n\n` + + `See: [Setting up the local repository](https://developers.esphome.io/contributing/development-environment/?h=org#set-up-the-local-repository) for more details.`; + } else { + body = `${ORG_FORK_MARKER}\n### ⚠️ Maintainer Access Disabled\n\n` + + `Hey there @${prAuthor},\n` + + `It looks like this PR does not have the "Allow edits from maintainers" option enabled. ` + + `This means we won't be able to make small adjustments or fixups to your PR directly.\n\n` + + `Please enable this option in the PR sidebar to allow maintainer collaboration.`; + } + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr_number, + body + }); + console.log('Created maintainer access warning comment'); +} + module.exports = { - handleReviews + handleReviews, + handleMaintainerAccessComment }; From c04dfa922ecb5b71782ef250e7ba59bf7b63caa7 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:02:49 +1200 Subject: [PATCH 015/575] [hbridge] Move light pin switching to loop (#15615) --- esphome/components/hbridge/light/__init__.py | 2 +- .../hbridge/light/hbridge_light_output.h | 52 ++++++++++++------- 2 files changed, 34 insertions(+), 20 deletions(-) diff --git a/esphome/components/hbridge/light/__init__.py b/esphome/components/hbridge/light/__init__.py index 65dd3196df..ccb47237b6 100644 --- a/esphome/components/hbridge/light/__init__.py +++ b/esphome/components/hbridge/light/__init__.py @@ -8,7 +8,7 @@ from .. import hbridge_ns CODEOWNERS = ["@DotNetDann"] HBridgeLightOutput = hbridge_ns.class_( - "HBridgeLightOutput", cg.PollingComponent, light.LightOutput + "HBridgeLightOutput", cg.Component, light.LightOutput ) CONFIG_SCHEMA = light.RGB_LIGHT_SCHEMA.extend( diff --git a/esphome/components/hbridge/light/hbridge_light_output.h b/esphome/components/hbridge/light/hbridge_light_output.h index c309154852..4e064d5352 100644 --- a/esphome/components/hbridge/light/hbridge_light_output.h +++ b/esphome/components/hbridge/light/hbridge_light_output.h @@ -1,20 +1,17 @@ #pragma once -#include "esphome/core/component.h" -#include "esphome/components/output/float_output.h" #include "esphome/components/light/light_output.h" -#include "esphome/core/log.h" +#include "esphome/components/output/float_output.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" namespace esphome { namespace hbridge { -// Using PollingComponent as the updates are more consistent and reduces flickering -class HBridgeLightOutput : public PollingComponent, public light::LightOutput { +class HBridgeLightOutput : public Component, public light::LightOutput { public: - HBridgeLightOutput() : PollingComponent(1) {} - - void set_pina_pin(output::FloatOutput *pina_pin) { pina_pin_ = pina_pin; } - void set_pinb_pin(output::FloatOutput *pinb_pin) { pinb_pin_ = pinb_pin; } + 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; } light::LightTraits get_traits() override { auto traits = light::LightTraits(); @@ -24,16 +21,16 @@ class HBridgeLightOutput : public PollingComponent, public light::LightOutput { return traits; } - void setup() override { this->forward_direction_ = false; } + void setup() override { this->disable_loop(); } - void update() override { - // This method runs around 60 times per second - // We cannot do the PWM ourselves so we are reliant on the hardware PWM - if (!this->forward_direction_) { // First LED Direction + void loop() override { + // Only called when both channels are active — alternate H-bridge direction + // each iteration to multiplex cold and warm white. + if (!this->forward_direction_) { this->pina_pin_->set_level(this->pina_duty_); this->pinb_pin_->set_level(0); this->forward_direction_ = true; - } else { // Second LED Direction + } else { this->pina_pin_->set_level(0); this->pinb_pin_->set_level(this->pinb_duty_); this->forward_direction_ = false; @@ -43,15 +40,32 @@ class HBridgeLightOutput : public PollingComponent, public light::LightOutput { float get_setup_priority() const override { return setup_priority::HARDWARE; } void write_state(light::LightState *state) override { - state->current_values_as_cwww(&this->pina_duty_, &this->pinb_duty_, false); + float new_pina, new_pinb; + state->current_values_as_cwww(&new_pina, &new_pinb, false); + + this->pina_duty_ = new_pina; + 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(); + } else { + // Zero or one channel active — drive pins directly, no multiplexing needed + this->high_freq_.stop(); + this->disable_loop(); + this->pina_pin_->set_level(new_pina); + this->pinb_pin_->set_level(new_pinb); + } } protected: output::FloatOutput *pina_pin_; output::FloatOutput *pinb_pin_; - float pina_duty_ = 0; - float pinb_duty_ = 0; - bool forward_direction_ = false; + float pina_duty_{0}; + float pinb_duty_{0}; + bool forward_direction_{false}; + HighFrequencyLoopRequester high_freq_; }; } // namespace hbridge From c90fa2378ab1fa8c5b30c118ff1f6ca5d10c21ac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Apr 2026 17:29:00 -1000 Subject: [PATCH 016/575] [tca9555] Add interrupt pin support (#15613) --- esphome/components/tca9555/__init__.py | 4 ++++ esphome/components/tca9555/tca9555.cpp | 19 ++++++++++++++++++- esphome/components/tca9555/tca9555.h | 5 +++++ tests/components/tca9555/common.yaml | 4 ++++ tests/components/tca9555/test.esp32-idf.yaml | 3 +++ .../components/tca9555/test.esp8266-ard.yaml | 3 +++ tests/components/tca9555/test.rp2040-ard.yaml | 3 +++ 7 files changed, 40 insertions(+), 1 deletion(-) diff --git a/esphome/components/tca9555/__init__.py b/esphome/components/tca9555/__init__.py index f42e0fe398..5f571fcea6 100644 --- a/esphome/components/tca9555/__init__.py +++ b/esphome/components/tca9555/__init__.py @@ -5,6 +5,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_ID, CONF_INPUT, + CONF_INTERRUPT_PIN, CONF_INVERTED, CONF_MODE, CONF_NUMBER, @@ -27,6 +28,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(TCA9555Component), + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, } ) .extend(cv.COMPONENT_SCHEMA) @@ -38,6 +40,8 @@ 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) + if interrupt_pin := config.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) def validate_mode(value): diff --git a/esphome/components/tca9555/tca9555.cpp b/esphome/components/tca9555/tca9555.cpp index 79c5253898..3eb794df44 100644 --- a/esphome/components/tca9555/tca9555.cpp +++ b/esphome/components/tca9555/tca9555.cpp @@ -24,9 +24,18 @@ void TCA9555Component::setup() { this->mark_failed(); return; } + + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->interrupt_pin_->attach_interrupt(&TCA9555Component::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + this->set_invalidate_on_read_(false); + } + this->disable_loop(); } +void IRAM_ATTR TCA9555Component::gpio_intr(TCA9555Component *arg) { arg->enable_loop_soon_any_context(); } void TCA9555Component::dump_config() { ESP_LOGCONFIG(TAG, "TCA9555:"); + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_I2C_DEVICE(this) if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); @@ -36,6 +45,9 @@ void TCA9555Component::pin_mode(uint8_t pin, gpio::Flags flags) { if (flags == gpio::FLAG_INPUT) { // Set mode mask bit this->mode_mask_ |= 1 << pin; + if (this->interrupt_pin_ == nullptr) { + this->enable_loop(); + } } else if (flags == gpio::FLAG_OUTPUT) { // Clear mode mask bit this->mode_mask_ &= ~(1 << pin); @@ -43,7 +55,12 @@ void TCA9555Component::pin_mode(uint8_t pin, gpio::Flags flags) { // Write GPIO to enable input mode this->write_gpio_modes_(); } -void TCA9555Component::loop() { this->reset_pin_cache_(); } +void TCA9555Component::loop() { + this->reset_pin_cache_(); + if (this->interrupt_pin_ != nullptr) { + this->disable_loop(); + } +} bool TCA9555Component::read_gpio_outputs_() { if (this->is_failed()) diff --git a/esphome/components/tca9555/tca9555.h b/esphome/components/tca9555/tca9555.h index 9f7273b1e7..d4d070013c 100644 --- a/esphome/components/tca9555/tca9555.h +++ b/esphome/components/tca9555/tca9555.h @@ -24,7 +24,10 @@ class TCA9555Component : public Component, void loop() override; + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + 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; @@ -39,6 +42,8 @@ class TCA9555Component : public Component, bool read_gpio_modes_(); bool write_gpio_modes_(); bool read_gpio_outputs_(); + + InternalGPIOPin *interrupt_pin_{nullptr}; }; /// Helper class to expose a TCA9555 pin as an internal input GPIO pin. diff --git a/tests/components/tca9555/common.yaml b/tests/components/tca9555/common.yaml index 82b4c959d8..d1a68c575a 100644 --- a/tests/components/tca9555/common.yaml +++ b/tests/components/tca9555/common.yaml @@ -2,6 +2,10 @@ tca9555: - id: tca9555_hub i2c_id: i2c_bus address: 0x21 + - id: tca9555_hub_int + i2c_id: i2c_bus + address: 0x22 + interrupt_pin: ${interrupt_pin} binary_sensor: - platform: gpio diff --git a/tests/components/tca9555/test.esp32-idf.yaml b/tests/components/tca9555/test.esp32-idf.yaml index b47e39c389..8c3b341dce 100644 --- a/tests/components/tca9555/test.esp32-idf.yaml +++ b/tests/components/tca9555/test.esp32-idf.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml diff --git a/tests/components/tca9555/test.esp8266-ard.yaml b/tests/components/tca9555/test.esp8266-ard.yaml index 4a98b9388a..69b243bfd8 100644 --- a/tests/components/tca9555/test.esp8266-ard.yaml +++ b/tests/components/tca9555/test.esp8266-ard.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + packages: i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml diff --git a/tests/components/tca9555/test.rp2040-ard.yaml b/tests/components/tca9555/test.rp2040-ard.yaml index 319a7c71a6..b8ad1e4792 100644 --- a/tests/components/tca9555/test.rp2040-ard.yaml +++ b/tests/components/tca9555/test.rp2040-ard.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO2 + packages: i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml From 9cf9b02ba2a3f1fe634859da93ee38c1ca25d30e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Apr 2026 17:29:26 -1000 Subject: [PATCH 017/575] [pca6416a] Add interrupt pin support (#15614) --- esphome/components/pca6416a/__init__.py | 10 +++++++++- esphome/components/pca6416a/pca6416a.cpp | 18 ++++++++++++++++++ esphome/components/pca6416a/pca6416a.h | 4 ++++ tests/components/pca6416a/common.yaml | 4 ++++ tests/components/pca6416a/test.esp32-idf.yaml | 3 +++ .../components/pca6416a/test.esp8266-ard.yaml | 3 +++ tests/components/pca6416a/test.rp2040-ard.yaml | 3 +++ 7 files changed, 44 insertions(+), 1 deletion(-) diff --git a/esphome/components/pca6416a/__init__.py b/esphome/components/pca6416a/__init__.py index b6e156e7ff..813bb35c48 100644 --- a/esphome/components/pca6416a/__init__.py +++ b/esphome/components/pca6416a/__init__.py @@ -5,6 +5,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_ID, CONF_INPUT, + CONF_INTERRUPT_PIN, CONF_INVERTED, CONF_MODE, CONF_NUMBER, @@ -25,7 +26,12 @@ PCA6416AGPIOPin = pca6416a_ns.class_( CONF_PCA6416A = "pca6416a" CONFIG_SCHEMA = ( - cv.Schema({cv.Required(CONF_ID): cv.declare_id(PCA6416AComponent)}) + cv.Schema( + { + cv.Required(CONF_ID): cv.declare_id(PCA6416AComponent), + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, + } + ) .extend(cv.COMPONENT_SCHEMA) .extend(i2c.i2c_device_schema(0x21)) ) @@ -35,6 +41,8 @@ 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) + if interrupt_pin := config.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) def validate_mode(value): diff --git a/esphome/components/pca6416a/pca6416a.cpp b/esphome/components/pca6416a/pca6416a.cpp index f393af88ce..dc7463b01b 100644 --- a/esphome/components/pca6416a/pca6416a.cpp +++ b/esphome/components/pca6416a/pca6416a.cpp @@ -49,11 +49,22 @@ void PCA6416AComponent::setup() { ESP_LOGD(TAG, "Initialization complete. Warning: %d, Error: %d", this->status_has_warning(), this->status_has_error()); + + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->interrupt_pin_->attach_interrupt(&PCA6416AComponent::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + this->set_invalidate_on_read_(false); + } + this->disable_loop(); } +void IRAM_ATTR PCA6416AComponent::gpio_intr(PCA6416AComponent *arg) { arg->enable_loop_soon_any_context(); } void PCA6416AComponent::loop() { // Invalidate cache at the start of each loop this->reset_pin_cache_(); + if (this->interrupt_pin_ != nullptr) { + this->disable_loop(); + } } void PCA6416AComponent::dump_config() { @@ -62,6 +73,7 @@ void PCA6416AComponent::dump_config() { } else { ESP_LOGCONFIG(TAG, "PCA6416A:"); } + LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_); LOG_I2C_DEVICE(this) if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); @@ -101,6 +113,9 @@ void PCA6416AComponent::pin_mode(uint8_t pin, gpio::Flags flags) { this->update_register_(pin, true, pull_dir); this->update_register_(pin, false, pull_en); } + if (this->interrupt_pin_ == nullptr) { + this->enable_loop(); + } } else if (flags == (gpio::FLAG_INPUT | gpio::FLAG_PULLUP)) { this->update_register_(pin, true, io_dir); if (has_pullup_) { @@ -109,6 +124,9 @@ void PCA6416AComponent::pin_mode(uint8_t pin, gpio::Flags flags) { } else { ESP_LOGW(TAG, "Your PCA6416A does not support pull-up resistors"); } + if (this->interrupt_pin_ == nullptr) { + this->enable_loop(); + } } else if (flags == gpio::FLAG_OUTPUT) { this->update_register_(pin, false, io_dir); } diff --git a/esphome/components/pca6416a/pca6416a.h b/esphome/components/pca6416a/pca6416a.h index 138a51cc20..4d2e6b219e 100644 --- a/esphome/components/pca6416a/pca6416a.h +++ b/esphome/components/pca6416a/pca6416a.h @@ -24,7 +24,10 @@ class PCA6416AComponent : public Component, void dump_config() override; + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + protected: + static void IRAM_ATTR gpio_intr(PCA6416AComponent *arg); // Virtual methods from CachedGpioExpander bool digital_read_hw(uint8_t pin) override; bool digital_read_cache(uint8_t pin) override; @@ -43,6 +46,7 @@ class PCA6416AComponent : public Component, esphome::i2c::ErrorCode last_error_; /// Only the PCAL6416A has pull-up resistors bool has_pullup_{false}; + InternalGPIOPin *interrupt_pin_{nullptr}; }; /// Helper class to expose a PCA6416A pin as an internal input GPIO pin. diff --git a/tests/components/pca6416a/common.yaml b/tests/components/pca6416a/common.yaml index 9ad6e2fb15..09083c6c15 100644 --- a/tests/components/pca6416a/common.yaml +++ b/tests/components/pca6416a/common.yaml @@ -2,6 +2,10 @@ pca6416a: - id: pca6416a_hub i2c_id: i2c_bus address: 0x21 + - id: pca6416a_hub_int + i2c_id: i2c_bus + address: 0x22 + interrupt_pin: ${interrupt_pin} binary_sensor: - platform: gpio diff --git a/tests/components/pca6416a/test.esp32-idf.yaml b/tests/components/pca6416a/test.esp32-idf.yaml index b47e39c389..8c3b341dce 100644 --- a/tests/components/pca6416a/test.esp32-idf.yaml +++ b/tests/components/pca6416a/test.esp32-idf.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml diff --git a/tests/components/pca6416a/test.esp8266-ard.yaml b/tests/components/pca6416a/test.esp8266-ard.yaml index 4a98b9388a..69b243bfd8 100644 --- a/tests/components/pca6416a/test.esp8266-ard.yaml +++ b/tests/components/pca6416a/test.esp8266-ard.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + packages: i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml diff --git a/tests/components/pca6416a/test.rp2040-ard.yaml b/tests/components/pca6416a/test.rp2040-ard.yaml index 319a7c71a6..b8ad1e4792 100644 --- a/tests/components/pca6416a/test.rp2040-ard.yaml +++ b/tests/components/pca6416a/test.rp2040-ard.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO2 + packages: i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml From 17209df7b5f97ad3dc6d4442366959661362bf32 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Apr 2026 17:29:52 -1000 Subject: [PATCH 018/575] [mcp23016] Add interrupt pin support (#15616) --- esphome/components/mcp23016/__init__.py | 4 ++++ esphome/components/mcp23016/mcp23016.cpp | 14 ++++++++++++++ esphome/components/mcp23016/mcp23016.h | 4 ++++ tests/components/mcp23016/common.yaml | 8 ++++++-- tests/components/mcp23016/test.esp32-idf.yaml | 3 +++ tests/components/mcp23016/test.esp8266-ard.yaml | 3 +++ tests/components/mcp23016/test.rp2040-ard.yaml | 3 +++ 7 files changed, 37 insertions(+), 2 deletions(-) diff --git a/esphome/components/mcp23016/__init__.py b/esphome/components/mcp23016/__init__.py index 5a1f011617..b71d57498a 100644 --- a/esphome/components/mcp23016/__init__.py +++ b/esphome/components/mcp23016/__init__.py @@ -5,6 +5,7 @@ import esphome.config_validation as cv from esphome.const import ( CONF_ID, CONF_INPUT, + CONF_INTERRUPT_PIN, CONF_INVERTED, CONF_MODE, CONF_NUMBER, @@ -24,6 +25,7 @@ CONFIG_SCHEMA = ( cv.Schema( { cv.Required(CONF_ID): cv.declare_id(MCP23016), + cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema, } ) .extend(cv.COMPONENT_SCHEMA) @@ -35,6 +37,8 @@ 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) + if interrupt_pin := config.get(CONF_INTERRUPT_PIN): + cg.add(var.set_interrupt_pin(await cg.gpio_pin_expression(interrupt_pin))) def validate_mode(value): diff --git a/esphome/components/mcp23016/mcp23016.cpp b/esphome/components/mcp23016/mcp23016.cpp index fbdb6903b8..118a77ce37 100644 --- a/esphome/components/mcp23016/mcp23016.cpp +++ b/esphome/components/mcp23016/mcp23016.cpp @@ -24,11 +24,22 @@ void MCP23016::setup() { // all pins input this->write_reg_(MCP23016_IODIR1, 0xFFFF); + + if (this->interrupt_pin_ != nullptr) { + this->interrupt_pin_->setup(); + this->interrupt_pin_->attach_interrupt(&MCP23016::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + this->set_invalidate_on_read_(false); + } + this->disable_loop(); } +void IRAM_ATTR MCP23016::gpio_intr(MCP23016 *arg) { arg->enable_loop_soon_any_context(); } void MCP23016::loop() { // Invalidate cache at the start of each loop this->reset_pin_cache_(); + if (this->interrupt_pin_ != nullptr) { + this->disable_loop(); + } } bool MCP23016::digital_read_hw(uint8_t pin) { return this->read_reg_(MCP23016_GP1, &this->input_mask_); } @@ -37,6 +48,9 @@ void MCP23016::digital_write_hw(uint8_t pin, bool value) { this->update_reg_(pin void MCP23016::pin_mode(uint8_t pin, gpio::Flags flags) { if (flags == gpio::FLAG_INPUT) { this->update_reg_(pin, true, MCP23016_IODIR1); + if (this->interrupt_pin_ == nullptr) { + this->enable_loop(); + } } else if (flags == gpio::FLAG_OUTPUT) { this->update_reg_(pin, false, MCP23016_IODIR1); } diff --git a/esphome/components/mcp23016/mcp23016.h b/esphome/components/mcp23016/mcp23016.h index 494bc9c197..32149ba3e2 100644 --- a/esphome/components/mcp23016/mcp23016.h +++ b/esphome/components/mcp23016/mcp23016.h @@ -35,7 +35,10 @@ class MCP23016 : public Component, public i2c::I2CDevice, public gpio_expander:: float get_setup_priority() const override; + void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; } + protected: + static void IRAM_ATTR gpio_intr(MCP23016 *arg); // Virtual methods from CachedGpioExpander bool digital_read_hw(uint8_t pin) override; bool digital_read_cache(uint8_t pin) override; @@ -51,6 +54,7 @@ class MCP23016 : public Component, public i2c::I2CDevice, public gpio_expander:: uint16_t olat_{0x0000}; // Cache for input values (16-bit combined for both banks) uint16_t input_mask_{0x0000}; + InternalGPIOPin *interrupt_pin_{nullptr}; }; class MCP23016GPIOPin : public GPIOPin { diff --git a/tests/components/mcp23016/common.yaml b/tests/components/mcp23016/common.yaml index e8e3ad9d08..81f38b3f52 100644 --- a/tests/components/mcp23016/common.yaml +++ b/tests/components/mcp23016/common.yaml @@ -1,6 +1,10 @@ mcp23016: - i2c_id: i2c_bus - id: mcp23016_hub + - i2c_id: i2c_bus + id: mcp23016_hub + - i2c_id: i2c_bus + id: mcp23016_hub_int + address: 0x21 + interrupt_pin: ${interrupt_pin} binary_sensor: - platform: gpio diff --git a/tests/components/mcp23016/test.esp32-idf.yaml b/tests/components/mcp23016/test.esp32-idf.yaml index b47e39c389..8c3b341dce 100644 --- a/tests/components/mcp23016/test.esp32-idf.yaml +++ b/tests/components/mcp23016/test.esp32-idf.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + packages: i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml diff --git a/tests/components/mcp23016/test.esp8266-ard.yaml b/tests/components/mcp23016/test.esp8266-ard.yaml index 4a98b9388a..69b243bfd8 100644 --- a/tests/components/mcp23016/test.esp8266-ard.yaml +++ b/tests/components/mcp23016/test.esp8266-ard.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO15 + packages: i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml diff --git a/tests/components/mcp23016/test.rp2040-ard.yaml b/tests/components/mcp23016/test.rp2040-ard.yaml index 319a7c71a6..b8ad1e4792 100644 --- a/tests/components/mcp23016/test.rp2040-ard.yaml +++ b/tests/components/mcp23016/test.rp2040-ard.yaml @@ -1,3 +1,6 @@ +substitutions: + interrupt_pin: GPIO2 + packages: i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml From ec420d57923bb6c38bf780e1bfcf854e48b51896 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Apr 2026 17:33:56 -1000 Subject: [PATCH 019/575] [api] Add (inline_encode) proto option for sub-message inlining (#15599) --- esphome/components/api/api.proto | 1 + esphome/components/api/api_options.proto | 1 + esphome/components/api/api_pb2.cpp | 47 ++-- esphome/components/api/api_pb2.h | 2 - esphome/components/api/proto.h | 6 + script/api_protobuf/api_protobuf.py | 293 +++++++++++++++++++++-- 6 files changed, 304 insertions(+), 46 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..c2d513f0d3 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -2328,40 +2328,37 @@ 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; + 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; } 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]; + 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/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/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 526644842d..39bfc865d0 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 (max(bits, 1) + 6) // 7 # ceil(bits / 7), min 1 byte for varint(0) + 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" @@ -807,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): @@ -1122,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. @@ -1299,14 +1366,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 +1404,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 +1457,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 +1483,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 +1509,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 +1530,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 +1580,91 @@ 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(f"auto &sub_msg = {element};") + lines.append(f"ProtoEncode::write_raw_byte(pos, {tag});") + 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: + 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(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) + + +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(f"auto &sub_msg = {element};") + # 1 byte tag + 1 byte length (guaranteed < 128 by validation) + lines.append("size += 2;") + + 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 hardcoded this-> references (e.g., FixedArrayBytesType uses this->field_len) + size_line = size_line.replace("this->", "sub_msg.") + lines.extend(wrap_with_ifdef(size_line, get_field_opt(field, pb.field_ifdef))) + + return "\n".join(lines) + + class FixedArrayRepeatedType(TypeInfo): """Special type for fixed-size repeated fields using std::array. @@ -1526,6 +1691,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 +1802,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 +1822,14 @@ class FixedArrayRepeatedType(TypeInfo): # For fixed arrays, we always encode all elements + 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 + # 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 +1902,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 +2419,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,11 +2670,23 @@ 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. + inline_opt = getattr(pb, "inline_encode", None) + is_inline_only = ( + message_id is None # Not a service message (no id) + 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: + 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" @@ -2470,7 +2701,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 +3061,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 +3305,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 d3591c8d9e3724e21a508c58d4f4fe7459e17aa9 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 10 Apr 2026 21:21:26 +0200 Subject: [PATCH 020/575] [micro_wake_word] Pin esp-nn version (#15628) --- .../components/micro_wake_word/__init__.py | 2 + .../micro_wake_word/streaming_model.cpp | 92 +++++++++++++++++-- .../micro_wake_word/streaming_model.h | 5 + 3 files changed, 91 insertions(+), 8 deletions(-) diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index ff27dec6df..de95e4961b 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -451,6 +451,8 @@ async def to_code(config): ota.request_ota_state_listeners() 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") cg.add_build_flag("-DTF_LITE_STATIC_MEMORY") cg.add_build_flag("-DTF_LITE_DISABLE_X86_NEON") diff --git a/esphome/components/micro_wake_word/streaming_model.cpp b/esphome/components/micro_wake_word/streaming_model.cpp index 0ab6cd3772..e761e4866f 100644 --- a/esphome/components/micro_wake_word/streaming_model.cpp +++ b/esphome/components/micro_wake_word/streaming_model.cpp @@ -29,14 +29,6 @@ void VADModel::log_model_config() { bool StreamingModel::load_model_() { RAMAllocator arena_allocator; - if (this->tensor_arena_ == nullptr) { - this->tensor_arena_ = arena_allocator.allocate(this->tensor_arena_size_); - if (this->tensor_arena_ == nullptr) { - ESP_LOGE(TAG, "Could not allocate the streaming model's tensor arena."); - return false; - } - } - if (this->var_arena_ == nullptr) { this->var_arena_ = arena_allocator.allocate(STREAMING_MODEL_VARIABLE_ARENA_SIZE); if (this->var_arena_ == nullptr) { @@ -53,6 +45,26 @@ bool StreamingModel::load_model_() { return false; } + // Probe for the actual required tensor arena size if not yet determined + if (!this->tensor_arena_size_probed_) { + size_t probed_size = this->probe_arena_size_(); + if (probed_size > 0) { + ESP_LOGD(TAG, "Probed tensor arena size: %zu bytes", probed_size); + this->tensor_arena_size_ = probed_size; + } else { + ESP_LOGW(TAG, "Arena size probe failed, using manifest size: %zu bytes", this->tensor_arena_size_); + } + this->tensor_arena_size_probed_ = true; + } + + if (this->tensor_arena_ == nullptr) { + this->tensor_arena_ = arena_allocator.allocate(this->tensor_arena_size_); + if (this->tensor_arena_ == nullptr) { + ESP_LOGE(TAG, "Could not allocate the streaming model's tensor arena."); + return false; + } + } + if (this->interpreter_ == nullptr) { this->interpreter_ = make_unique(tflite::GetModel(this->model_start_), this->streaming_op_resolver_, @@ -94,6 +106,70 @@ bool StreamingModel::load_model_() { return true; } +size_t StreamingModel::probe_arena_size_() { + RAMAllocator arena_allocator; + + // Try with the manifest size first, then escalates to 1.5, then 2x if it fails. Different platforms and different + // versions of the esp-nn library require different amounts of memory, so the manifest size may not always be correct, + // and probing allows us to find the actual required size for the current build and platform. Aligns test sizes to 16 + // bytes. + size_t attempt_sizes[] = {(this->tensor_arena_size_ + 15) & ~15, (this->tensor_arena_size_ * 3 / 2 + 15) & ~15, + (this->tensor_arena_size_ * 2 + 15) & ~15}; + + for (size_t attempt_size : attempt_sizes) { + uint8_t *probe_arena = arena_allocator.allocate(attempt_size); + if (probe_arena == nullptr) { + continue; + } + + // Verify the model works at all with this arena size + auto probe_interpreter = make_unique( + tflite::GetModel(this->model_start_), this->streaming_op_resolver_, probe_arena, attempt_size, this->mrv_); + + if (probe_interpreter->AllocateTensors() != kTfLiteOk) { + probe_interpreter.reset(); + arena_allocator.deallocate(probe_arena, attempt_size); + this->ma_ = tflite::MicroAllocator::Create(this->var_arena_, STREAMING_MODEL_VARIABLE_ARENA_SIZE); + this->mrv_ = tflite::MicroResourceVariables::Create(this->ma_, 20); + continue; + } + + // Try to shrink the arena. Start with arena_used_bytes() + 16 (rounded to 16-byte alignment). + // If that works, use it. Otherwise, try midpoints between that and the full size until one succeeds. + size_t lower = (probe_interpreter->arena_used_bytes() + 16 + 15) & ~15; + probe_interpreter.reset(); + this->ma_ = tflite::MicroAllocator::Create(this->var_arena_, STREAMING_MODEL_VARIABLE_ARENA_SIZE); + this->mrv_ = tflite::MicroResourceVariables::Create(this->ma_, 20); + + size_t upper = attempt_size; + + while (lower < upper) { + auto test_interpreter = make_unique( + tflite::GetModel(this->model_start_), this->streaming_op_resolver_, probe_arena, lower, this->mrv_); + + bool ok = test_interpreter->AllocateTensors() == kTfLiteOk; + + test_interpreter.reset(); + this->ma_ = tflite::MicroAllocator::Create(this->var_arena_, STREAMING_MODEL_VARIABLE_ARENA_SIZE); + this->mrv_ = tflite::MicroResourceVariables::Create(this->ma_, 20); + + if (ok) { + // Found a working size smaller than the full arena + upper = lower + 16; // Pad by 16 bytes to be safe for future allocations + break; + } + + // Try the midpoint between current attempt and full size + lower = ((lower + upper) / 2 + 15) & ~15; + } + + arena_allocator.deallocate(probe_arena, attempt_size); + return upper; + } + + return 0; +} + void StreamingModel::unload_model() { this->interpreter_.reset(); diff --git a/esphome/components/micro_wake_word/streaming_model.h b/esphome/components/micro_wake_word/streaming_model.h index 0811bfb19b..fc9eeb5e2d 100644 --- a/esphome/components/micro_wake_word/streaming_model.h +++ b/esphome/components/micro_wake_word/streaming_model.h @@ -63,6 +63,10 @@ class StreamingModel { /// @brief Allocates tensor and variable arenas and sets up the model interpreter /// @return True if successful, false otherwise bool load_model_(); + /// @brief Probes the actual required tensor arena size by trial allocation. + /// Tries the manifest size first, then 2x if that fails. + /// @return The required arena size rounded up to 16-byte alignment, or 0 on failure. + size_t probe_arena_size_(); /// @brief Returns true if successfully registered the streaming model's TensorFlow operations bool register_streaming_ops_(tflite::MicroMutableOpResolver<20> &op_resolver); @@ -70,6 +74,7 @@ class StreamingModel { bool loaded_{false}; bool enabled_{true}; + bool tensor_arena_size_probed_{false}; bool unprocessed_probability_status_{false}; uint8_t current_stride_step_{0}; int16_t ignore_windows_{-MIN_SLICES_BEFORE_DETECTION}; From 2c610abcd010bfa0fbdae5e52f7ca83cca8b1bfb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:19:52 -1000 Subject: [PATCH 021/575] Bump resvg-py from 0.2.6 to 0.3.0 (#15629) 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 d7db44454c..8f8ada561a 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.2.6 +resvg-py==0.3.0 freetype-py==2.5.1 jinja2==3.1.6 bleak==2.1.1 From ae96f82b824682da5d855b8883fc76520b9f0276 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:20:04 -1000 Subject: [PATCH 022/575] Bump actions/upload-artifact from 7.0.0 to 7.0.1 (#15631) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 6d200956e9..677032b7fa 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -62,7 +62,7 @@ jobs: run: git diff - if: failure() name: Archive artifacts - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: generated-proto-files path: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf9fa8e7c0..2240879bd2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -904,7 +904,7 @@ jobs: fi - name: Upload memory analysis JSON - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: memory-analysis-target path: memory-analysis-target.json @@ -969,7 +969,7 @@ jobs: --platform "$platform" - name: Upload memory analysis JSON - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: memory-analysis-pr path: memory-analysis-pr.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e8a040888..12d2ce30aa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -138,7 +138,7 @@ jobs: # version: ${{ needs.init.outputs.tag }} - name: Upload digests - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: digests-${{ matrix.platform.arch }} path: /tmp/digests From 395610c117bd9358c77f0903890f27c0c65ccf65 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:20:17 -1000 Subject: [PATCH 023/575] Bump docker/build-push-action from 7.0.0 to 7.1.0 in /.github/actions/build-image (#15633) 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 a895226030..52d72544d3 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@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.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@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 env: DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_RECORD_UPLOAD: false From 1dfeef0265c7fe7a6bf322a3d79b08aeb1926c2f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:20:43 -1000 Subject: [PATCH 024/575] Bump actions/github-script from 8.0.0 to 9.0.0 (#15632) 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 | 4 ++-- .github/workflows/ci-clang-tidy-hash.yml | 4 ++-- .github/workflows/codeowner-approved-label-update.yml | 2 +- .github/workflows/codeowner-review-request.yml | 2 +- .github/workflows/external-component-bot.yml | 2 +- .github/workflows/issue-codeowner-notify.yml | 2 +- .github/workflows/pr-title-check.yml | 2 +- .github/workflows/release.yml | 6 +++--- .github/workflows/status-check-labels.yml | 2 +- 10 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index 3b5e9f0d15..27ddfe5911 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -33,7 +33,7 @@ jobs: private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} - name: Auto Label PR - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ steps.generate-token.outputs.token }} script: | diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 677032b7fa..e5143911d9 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -47,7 +47,7 @@ jobs: fi - if: failure() name: Review PR - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | await github.rest.pulls.createReview({ @@ -70,7 +70,7 @@ jobs: esphome/components/api/api_pb2_service.* - if: success() name: Dismiss review - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | let reviews = await github.rest.pulls.listReviews({ diff --git a/.github/workflows/ci-clang-tidy-hash.yml b/.github/workflows/ci-clang-tidy-hash.yml index 7905739b15..40cdff0cba 100644 --- a/.github/workflows/ci-clang-tidy-hash.yml +++ b/.github/workflows/ci-clang-tidy-hash.yml @@ -42,7 +42,7 @@ jobs: - if: failure() && github.event.pull_request.head.repo.full_name == github.repository name: Request changes - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | await github.rest.pulls.createReview({ @@ -55,7 +55,7 @@ jobs: - if: success() && github.event.pull_request.head.repo.full_name == github.repository name: Dismiss review - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | let reviews = await github.rest.pulls.listReviews({ diff --git a/.github/workflows/codeowner-approved-label-update.yml b/.github/workflows/codeowner-approved-label-update.yml index 34ff934b77..49653b6fb3 100644 --- a/.github/workflows/codeowner-approved-label-update.yml +++ b/.github/workflows/codeowner-approved-label-update.yml @@ -34,7 +34,7 @@ jobs: CODEOWNERS - name: Check codeowner approval and update label - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: PR_NUMBER: ${{ github.event.pull_request.number }} with: diff --git a/.github/workflows/codeowner-review-request.yml b/.github/workflows/codeowner-review-request.yml index a89c03ba04..76be6ecd7b 100644 --- a/.github/workflows/codeowner-review-request.yml +++ b/.github/workflows/codeowner-review-request.yml @@ -33,7 +33,7 @@ jobs: ref: ${{ github.event.pull_request.base.sha }} - name: Request reviews from component codeowners - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const { loadCodeowners, getEffectiveOwners } = require('./.github/scripts/codeowners.js'); diff --git a/.github/workflows/external-component-bot.yml b/.github/workflows/external-component-bot.yml index 4fa020f63d..3165b17078 100644 --- a/.github/workflows/external-component-bot.yml +++ b/.github/workflows/external-component-bot.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Add external component comment - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/issue-codeowner-notify.yml b/.github/workflows/issue-codeowner-notify.yml index 6faf956c87..b211c13985 100644 --- a/.github/workflows/issue-codeowner-notify.yml +++ b/.github/workflows/issue-codeowner-notify.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Notify codeowners for component issues - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const owner = context.repo.owner; diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index 0021654def..8700996271 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -18,7 +18,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const { diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 12d2ce30aa..c92581b49b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -229,7 +229,7 @@ jobs: repositories: home-assistant-addon - name: Trigger Workflow - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ steps.generate-token.outputs.token }} script: | @@ -264,7 +264,7 @@ jobs: repositories: esphome-schema - name: Trigger Workflow - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ steps.generate-token.outputs.token }} script: | @@ -295,7 +295,7 @@ jobs: repositories: version-notifier - name: Trigger Workflow - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ steps.generate-token.outputs.token }} script: | diff --git a/.github/workflows/status-check-labels.yml b/.github/workflows/status-check-labels.yml index 6483bbe789..709342e5ae 100644 --- a/.github/workflows/status-check-labels.yml +++ b/.github/workflows/status-check-labels.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check for blocking labels - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const blockingLabels = ['needs-docs', 'merge-after-release', 'chained-pr']; From e1a813e11fc26d9ebc86d6e7fe0e6a7d38f00f30 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:21:01 -1000 Subject: [PATCH 025/575] Bump peter-evans/create-pull-request from 8.1.0 to 8.1.1 (#15630) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/sync-device-classes.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index a71e5ef4ca..be1457387d 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -41,7 +41,7 @@ jobs: python script/run-in-env.py pre-commit run --all-files - name: Commit changes - uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: commit-message: "Synchronise Device Classes from Home Assistant" committer: esphomebot From a7c5b0ab466d6f75279cd8228036856fb118cb2a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Fri, 10 Apr 2026 16:26:09 -0400 Subject: [PATCH 026/575] [sx127x][cc1101][sx126x] Use GPIO interrupt to wake loop (#15627) --- esphome/components/cc1101/cc1101.cpp | 25 ++++++++++++++++--------- esphome/components/cc1101/cc1101.h | 1 + esphome/components/sx126x/sx126x.cpp | 9 +++++++++ esphome/components/sx126x/sx126x.h | 2 ++ esphome/components/sx127x/sx127x.cpp | 9 ++++----- esphome/components/sx127x/sx127x.h | 2 ++ 6 files changed, 34 insertions(+), 14 deletions(-) diff --git a/esphome/components/cc1101/cc1101.cpp b/esphome/components/cc1101/cc1101.cpp index f2b7451721..c231f314cc 100644 --- a/esphome/components/cc1101/cc1101.cpp +++ b/esphome/components/cc1101/cc1101.cpp @@ -102,6 +102,8 @@ CC1101Component::CC1101Component() { memset(this->pa_table_, 0, sizeof(this->pa_table_)); } +void IRAM_ATTR CC1101Component::gpio_intr(CC1101Component *arg) { arg->enable_loop_soon_any_context(); } + void CC1101Component::setup() { this->spi_setup(); this->cs_->digital_write(true); @@ -148,11 +150,12 @@ void CC1101Component::setup() { // Defer pin mode setup until after all components have completed setup() // This handles the case where remote_transmitter runs after CC1101 and changes pin mode if (this->gdo0_pin_ != nullptr) { - this->defer([this]() { this->gdo0_pin_->pin_mode(gpio::FLAG_INPUT); }); - } - - if (this->state_.PKT_FORMAT != static_cast(PacketFormat::PACKET_FORMAT_FIFO)) { - this->disable_loop(); + this->defer([this]() { + this->gdo0_pin_->pin_mode(gpio::FLAG_INPUT); + if (this->state_.PKT_FORMAT == static_cast(PacketFormat::PACKET_FORMAT_FIFO)) { + this->gdo0_pin_->attach_interrupt(&CC1101Component::gpio_intr, this, gpio::INTERRUPT_RISING_EDGE); + } + }); } } @@ -164,6 +167,7 @@ void CC1101Component::call_listeners_(const std::vector &packet, float } void CC1101Component::loop() { + this->disable_loop(); if (this->state_.PKT_FORMAT != static_cast(PacketFormat::PACKET_FORMAT_FIFO) || this->gdo0_pin_ == nullptr || !this->gdo0_pin_->digital_read()) { return; @@ -244,6 +248,7 @@ void CC1101Component::begin_tx() { this->write_(Register::PKTCTRL0, 0x32); ESP_LOGV(TAG, "Beginning TX sequence"); if (this->gdo0_pin_ != nullptr) { + this->gdo0_pin_->detach_interrupt(); this->gdo0_pin_->pin_mode(gpio::FLAG_OUTPUT); } // Transition through IDLE to bypass CCA (Clear Channel Assessment) which can @@ -673,10 +678,12 @@ void CC1101Component::set_packet_mode(bool value) { this->state_.GDO0_CFG = 0x0D; } if (this->initialized_) { - if (value) { - this->enable_loop(); - } else { - this->disable_loop(); + if (this->gdo0_pin_ != nullptr) { + if (value) { + this->gdo0_pin_->attach_interrupt(&CC1101Component::gpio_intr, this, gpio::INTERRUPT_RISING_EDGE); + } else { + this->gdo0_pin_->detach_interrupt(); + } } this->write_(Register::PKTCTRL0); this->write_(Register::PKTCTRL1); diff --git a/esphome/components/cc1101/cc1101.h b/esphome/components/cc1101/cc1101.h index 2efd9e082d..68d81ac8f3 100644 --- a/esphome/components/cc1101/cc1101.h +++ b/esphome/components/cc1101/cc1101.h @@ -93,6 +93,7 @@ class CC1101Component : public Component, // GDO pin for packet reception InternalGPIOPin *gdo0_pin_{nullptr}; + static void IRAM_ATTR gpio_intr(CC1101Component *arg); // Packet handling void call_listeners_(const std::vector &packet, float freq_offset, float rssi, uint8_t lqi); diff --git a/esphome/components/sx126x/sx126x.cpp b/esphome/components/sx126x/sx126x.cpp index ec62fad10a..6ea09e3a9e 100644 --- a/esphome/components/sx126x/sx126x.cpp +++ b/esphome/components/sx126x/sx126x.cpp @@ -104,11 +104,17 @@ void SX126x::write_register_(uint16_t reg, uint8_t *data, uint8_t size) { delayMicroseconds(SWITCHING_DELAY_US); } +void IRAM_ATTR SX126x::gpio_intr(SX126x *arg) { arg->enable_loop_soon_any_context(); } + void SX126x::setup() { // setup pins this->busy_pin_->setup(); this->rst_pin_->setup(); this->dio1_pin_->setup(); + if (this->dio1_pin_->is_internal()) { + static_cast(this->dio1_pin_) + ->attach_interrupt(&SX126x::gpio_intr, this, gpio::INTERRUPT_RISING_EDGE); + } // start spi this->spi_setup(); @@ -348,6 +354,9 @@ void SX126x::call_listeners_(const std::vector &packet, float rssi, flo } void SX126x::loop() { + if (this->dio1_pin_->is_internal()) { + this->disable_loop(); + } if (!this->dio1_pin_->digital_read()) { return; } diff --git a/esphome/components/sx126x/sx126x.h b/esphome/components/sx126x/sx126x.h index a758d63795..edc00e3727 100644 --- a/esphome/components/sx126x/sx126x.h +++ b/esphome/components/sx126x/sx126x.h @@ -3,6 +3,7 @@ #include "esphome/components/spi/spi.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" +#include "esphome/core/hal.h" #include "sx126x_reg.h" #include #include @@ -100,6 +101,7 @@ class SX126x : public Component, Trigger, float, float> *get_packet_trigger() { return &this->packet_trigger_; } protected: + static void IRAM_ATTR gpio_intr(SX126x *arg); void configure_fsk_ook_(); void configure_lora_(); void set_packet_params_(uint8_t payload_length); diff --git a/esphome/components/sx127x/sx127x.cpp b/esphome/components/sx127x/sx127x.cpp index 83be96767a..2b13efb38d 100644 --- a/esphome/components/sx127x/sx127x.cpp +++ b/esphome/components/sx127x/sx127x.cpp @@ -53,6 +53,8 @@ void SX127x::write_fifo_(const std::vector &packet) { this->disable(); } +void IRAM_ATTR SX127x::gpio_intr(SX127x *arg) { arg->enable_loop_soon_any_context(); } + void SX127x::setup() { // setup reset this->rst_pin_->setup(); @@ -60,6 +62,7 @@ void SX127x::setup() { // setup dio0 if (this->dio0_pin_) { this->dio0_pin_->setup(); + this->dio0_pin_->attach_interrupt(&SX127x::gpio_intr, this, gpio::INTERRUPT_RISING_EDGE); } // start spi @@ -313,6 +316,7 @@ void SX127x::call_listeners_(const std::vector &packet, float rssi, flo } void SX127x::loop() { + this->disable_loop(); if (this->dio0_pin_ == nullptr || !this->dio0_pin_->digital_read()) { return; } @@ -386,11 +390,6 @@ void SX127x::set_mode_(uint8_t modulation, uint8_t mode) { return; } } - if (mode == MODE_RX && (modulation == MOD_LORA || this->packet_mode_)) { - this->enable_loop(); - } else { - this->disable_loop(); - } } void SX127x::set_mode_rx() { diff --git a/esphome/components/sx127x/sx127x.h b/esphome/components/sx127x/sx127x.h index be7b6d8d9f..76f942fdda 100644 --- a/esphome/components/sx127x/sx127x.h +++ b/esphome/components/sx127x/sx127x.h @@ -4,6 +4,7 @@ #include "esphome/components/spi/spi.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" +#include "esphome/core/hal.h" #include namespace esphome { @@ -86,6 +87,7 @@ class SX127x : public Component, Trigger, float, float> *get_packet_trigger() { return &this->packet_trigger_; } protected: + static void IRAM_ATTR gpio_intr(SX127x *arg); void configure_fsk_ook_(); void configure_lora_(); void set_mode_(uint8_t modulation, uint8_t mode); From 40081e5ae723e15870d56f5464a8e559dd71599c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Apr 2026 13:13:05 -1000 Subject: [PATCH 027/575] [rp2040] Fix W5500 Ethernet pbuf corruption by mirroring LWIPMutex semantics (#15624) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- 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..6e5ddad236 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 mirrors its semantics (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 5460ee7edde319ab3c3c2a79a4438321fdd86592 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:55:15 -1000 Subject: [PATCH 028/575] Bump aioesphomeapi from 44.13.1 to 44.13.2 (#15637) 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 8f8ada561a..12e7658e43 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.1 +aioesphomeapi==44.13.2 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import From 2001b912807e72df923eb7c6eccb15c41c6f0e65 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 11 Apr 2026 08:57:39 +0000 Subject: [PATCH 029/575] Bump resvg-py from 0.3.0 to 0.3.1 (#15640) 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 12e7658e43..361a628919 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.0 +resvg-py==0.3.1 freetype-py==2.5.1 jinja2==3.1.6 bleak==2.1.1 From c2af4874f952d3685c33f42e4bda7156497d2075 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 11 Apr 2026 08:58:20 +0000 Subject: [PATCH 030/575] Bump aioesphomeapi from 44.13.2 to 44.13.3 (#15641) 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 361a628919..1466eccc9b 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 6e6786451061b190bdf5a8ade02ac113ffd30c05 Mon Sep 17 00:00:00 2001 From: Farmer-shin Date: Sat, 11 Apr 2026 13:27:25 +0200 Subject: [PATCH 031/575] [epaper_spi] Add Waveshare 3.97inch E-Paper Display (#15466) --- esphome/components/epaper_spi/models/ssd1677.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/esphome/components/epaper_spi/models/ssd1677.py b/esphome/components/epaper_spi/models/ssd1677.py index f7e012f162..bad33a6a02 100644 --- a/esphome/components/epaper_spi/models/ssd1677.py +++ b/esphome/components/epaper_spi/models/ssd1677.py @@ -43,3 +43,11 @@ wave_4_26.extend( }, }, ) + + +ssd1677.extend( + "waveshare-3.97in", + width=800, + height=480, + mirror_x=True, +) From bef4c8a86c275c0c64bfe230c96a77fe5dd87835 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sat, 11 Apr 2026 17:36:27 -0400 Subject: [PATCH 032/575] [cc1101] Extract chip configuration into configure() method (#15635) --- esphome/components/cc1101/cc1101.cpp | 43 +++++++++++++++++----------- esphome/components/cc1101/cc1101.h | 1 + 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/esphome/components/cc1101/cc1101.cpp b/esphome/components/cc1101/cc1101.cpp index c231f314cc..ea0138e1dd 100644 --- a/esphome/components/cc1101/cc1101.cpp +++ b/esphome/components/cc1101/cc1101.cpp @@ -106,6 +106,30 @@ void IRAM_ATTR CC1101Component::gpio_intr(CC1101Component *arg) { arg->enable_lo void CC1101Component::setup() { this->spi_setup(); + + if (this->gdo0_pin_ != nullptr) { + this->gdo0_pin_->setup(); + } + + this->configure(); + if (this->is_failed()) { + return; + } + + // Defer pin mode setup until after all components have completed setup() + // This handles the case where remote_transmitter runs after CC1101 and changes pin mode + if (this->gdo0_pin_ != nullptr) { + this->defer([this]() { + this->gdo0_pin_->pin_mode(gpio::FLAG_INPUT); + if (this->state_.PKT_FORMAT == static_cast(PacketFormat::PACKET_FORMAT_FIFO)) { + this->gdo0_pin_->attach_interrupt(&CC1101Component::gpio_intr, this, gpio::INTERRUPT_RISING_EDGE); + } + }); + } +} + +void CC1101Component::configure() { + // Manual reset sequence per CC1101 datasheet section 19.1.2 this->cs_->digital_write(true); delayMicroseconds(1); this->cs_->digital_write(false); @@ -128,11 +152,6 @@ void CC1101Component::setup() { return; } - // Setup GDO0 pin if configured - if (this->gdo0_pin_ != nullptr) { - this->gdo0_pin_->setup(); - } - this->initialized_ = true; for (uint8_t i = 0; i <= static_cast(Register::TEST0); i++) { @@ -142,21 +161,11 @@ void CC1101Component::setup() { this->write_(static_cast(i)); } this->set_output_power(this->output_power_requested_); + if (!this->enter_rx_()) { this->mark_failed(); return; } - - // Defer pin mode setup until after all components have completed setup() - // This handles the case where remote_transmitter runs after CC1101 and changes pin mode - if (this->gdo0_pin_ != nullptr) { - this->defer([this]() { - this->gdo0_pin_->pin_mode(gpio::FLAG_INPUT); - if (this->state_.PKT_FORMAT == static_cast(PacketFormat::PACKET_FORMAT_FIFO)) { - this->gdo0_pin_->attach_interrupt(&CC1101Component::gpio_intr, this, gpio::INTERRUPT_RISING_EDGE); - } - }); - } } void CC1101Component::call_listeners_(const std::vector &packet, float freq_offset, float rssi, uint8_t lqi) { @@ -273,7 +282,7 @@ void CC1101Component::begin_rx() { void CC1101Component::reset() { this->strobe_(Command::RES); - this->setup(); + this->configure(); } void CC1101Component::set_idle() { diff --git a/esphome/components/cc1101/cc1101.h b/esphome/components/cc1101/cc1101.h index 68d81ac8f3..000a13d586 100644 --- a/esphome/components/cc1101/cc1101.h +++ b/esphome/components/cc1101/cc1101.h @@ -25,6 +25,7 @@ class CC1101Component : public Component, void setup() override; void loop() override; void dump_config() override; + void configure(); // Actions void begin_tx(); From e6318a2d1690ffee8b2729b8412f970f5452aba3 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 12 Apr 2026 15:54:30 -0400 Subject: [PATCH 033/575] [mdns] Bump espressif/mdns to 1.11.0 (#15670) --- esphome/components/mdns/__init__.py | 2 +- esphome/idf_component.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 79d355e8ae..7c36295e8d 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -170,7 +170,7 @@ async def to_code(config): cg.add_library("LEAmDNS", None) if CORE.is_esp32: - add_idf_component(name="espressif/mdns", ref="1.10.0") + add_idf_component(name="espressif/mdns", ref="1.11.0") cg.add_define("USE_MDNS") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 1e40fef2dc..bf42730e67 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -14,7 +14,7 @@ dependencies: espressif/esp32-camera: version: 2.1.6 espressif/mdns: - version: 1.10.0 + version: 1.11.0 espressif/esp_wifi_remote: version: 1.4.0 rules: From 45af21bf3813e10350e249513635fd5661d35954 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 12 Apr 2026 15:58:51 -0400 Subject: [PATCH 034/575] [canbus] Fix canbus.send can_id compile error (#15668) --- esphome/components/canbus/__init__.py | 1 - tests/components/canbus/common.yaml | 7 +++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/esphome/components/canbus/__init__.py b/esphome/components/canbus/__init__.py index 7d3bf78f49..fcd342ad38 100644 --- a/esphome/components/canbus/__init__.py +++ b/esphome/components/canbus/__init__.py @@ -162,7 +162,6 @@ async def canbus_action_to_code(config, action_id, template_arg, args): await cg.register_parented(var, config[CONF_CANBUS_ID]) if (can_id := config.get(CONF_CAN_ID)) is not None: - can_id = await cg.templatable(can_id, args, cg.uint32) cg.add(var.set_can_id(can_id)) cg.add(var.set_use_extended_id(config[CONF_USE_EXTENDED_ID])) diff --git a/tests/components/canbus/common.yaml b/tests/components/canbus/common.yaml index 8bddeb7409..e779f7f078 100644 --- a/tests/components/canbus/common.yaml +++ b/tests/components/canbus/common.yaml @@ -50,6 +50,13 @@ button: - platform: template name: Canbus Actions on_press: + - canbus.send: + can_id: 0x601 + data: [0, 1, 2] + - canbus.send: + can_id: 0x1FFFFFFF + use_extended_id: true + data: [0, 1, 2] - canbus.send: "abc" - canbus.send: [0, 1, 2] - canbus.send: !lambda return {0, 1, 2}; From 2f684bf4f3ac7d6e41d4bd7942ee3a3c73cdf59f Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Sun, 12 Apr 2026 16:07:04 -0400 Subject: [PATCH 035/575] [esp32] Bump platform to 55.03.38, Arduino to 3.3.8, ESP-IDF to 5.5.4 (#15666) --- .clang-tidy.hash | 2 +- esphome/components/esp32/__init__.py | 20 ++-- esphome/components/esp32/boards.py | 16 +++ esphome/platformio_api.py | 129 +----------------------- esphome/platformio_runner.py | 114 +++++++++++++++++++++ platformio.ini | 10 +- pyproject.toml | 1 - tests/unit_tests/conftest.py | 6 +- tests/unit_tests/test_platformio_api.py | 119 +++++++--------------- 9 files changed, 192 insertions(+), 225 deletions(-) create mode 100644 esphome/platformio_runner.py diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 5c7eab517b..cd61d9ec48 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -f31f13994768b5b07e29624406c9b053bf4bb26e1623ac2bc1e9d4a9477502d6 +d48687d988ae2a94a9973226df773478a7db1d52133545f07aa05e34fc678dcf diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index f27690c97b..cd38c82dd8 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -671,11 +671,12 @@ 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, 7), - "latest": cv.Version(3, 3, 7), - "dev": cv.Version(3, 3, 7), + "recommended": cv.Version(3, 3, 8), + "latest": cv.Version(3, 3, 8), + "dev": cv.Version(3, 3, 8), } ARDUINO_PLATFORM_VERSION_LOOKUP = { + cv.Version(3, 3, 8): cv.Version(55, 3, 38), 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), @@ -695,6 +696,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(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), cv.Version(3, 3, 5): cv.Version(5, 5, 2), @@ -714,17 +716,15 @@ 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, 3, "1"), - "latest": cv.Version(5, 5, 3, "1"), + "recommended": cv.Version(5, 5, 4), + "latest": cv.Version(5, 5, 4), "dev": cv.Version(5, 5, 4), } ESP_IDF_PLATFORM_VERSION_LOOKUP = { cv.Version( 6, 0, 0 ): "https://github.com/pioarduino/platform-espressif32.git#prep_IDF6", - cv.Version( - 5, 5, 4 - ): "https://github.com/pioarduino/platform-espressif32.git#develop", + cv.Version(5, 5, 4): cv.Version(55, 3, 38), 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, 37), - "latest": cv.Version(55, 3, 37), + "recommended": cv.Version(55, 3, 38), + "latest": cv.Version(55, 3, 38), "dev": "https://github.com/pioarduino/platform-espressif32.git#develop", } diff --git a/esphome/components/esp32/boards.py b/esphome/components/esp32/boards.py index 2bd08e7c39..2c73fe7d08 100644 --- a/esphome/components/esp32/boards.py +++ b/esphome/components/esp32/boards.py @@ -1960,6 +1960,10 @@ BOARDS = { "name": "Hornbill ESP32 Minima", "variant": VARIANT_ESP32, }, + "huidu_hd_wf1": { + "name": "Huidu HD-WF1", + "variant": VARIANT_ESP32S2, + }, "huidu_hd_wf2": { "name": "Huidu HD-WF2", "variant": VARIANT_ESP32S3, @@ -2028,6 +2032,10 @@ BOARDS = { "name": "LilyGo T-Display-S3", "variant": VARIANT_ESP32S3, }, + "lilygo-t-energy-s3": { + "name": "LilyGo T-Energy-S3", + "variant": VARIANT_ESP32S3, + }, "lilygo-t3-s3": { "name": "LilyGo T3-S3", "variant": VARIANT_ESP32S3, @@ -2289,10 +2297,18 @@ BOARDS = { "name": "S.ODI Ultra v1", "variant": VARIANT_ESP32, }, + "seeed_xiao_esp32_s3_plus": { + "name": "Seeed Studio XIAO ESP32S3 Plus", + "variant": VARIANT_ESP32S3, + }, "seeed_xiao_esp32c3": { "name": "Seeed Studio XIAO ESP32C3", "variant": VARIANT_ESP32C3, }, + "seeed_xiao_esp32c5": { + "name": "Seeed Studio XIAO ESP32C5", + "variant": VARIANT_ESP32C5, + }, "seeed_xiao_esp32c6": { "name": "Seeed Studio XIAO ESP32C6", "variant": VARIANT_ESP32C6, diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index cb080b2a95..e9719f7dcd 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio_api.py @@ -5,104 +5,15 @@ import os from pathlib import Path import re import subprocess -import time -from typing import Any +import sys from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE from esphome.core import CORE, EsphomeError -from esphome.util import run_external_command, run_external_process +from esphome.util import run_external_process _LOGGER = logging.getLogger(__name__) -def patch_structhash(): - # Patch platformio's structhash to not recompile the entire project when files are - # removed/added. This might have unintended consequences, but this improves compile - # times greatly when adding/removing components and a simple clean build solves - # all issues - from platformio.run import cli, helpers - - def patched_clean_build_dir(build_dir, *args): - from platformio import fs - from platformio.project.helpers import get_project_dir - - platformio_ini = Path(get_project_dir()) / "platformio.ini" - - build_dir = Path(build_dir) - - # if project's config is modified - if ( - build_dir.is_dir() - and platformio_ini.stat().st_mtime > build_dir.stat().st_mtime - ): - fs.rmtree(build_dir) - - if not build_dir.is_dir(): - build_dir.mkdir(parents=True) - - helpers.clean_build_dir = patched_clean_build_dir - cli.clean_build_dir = patched_clean_build_dir - - -def patch_file_downloader(): - """Patch PlatformIO's FileDownloader to retry on PackageException errors. - - PlatformIO's FileDownloader uses HTTPSession which lacks built-in retry - for 502/503 errors. We add retries with exponential backoff and close the - session between attempts to force a fresh TCP connection, which may route - to a different CDN edge node. - """ - from platformio.package.download import FileDownloader - from platformio.package.exception import PackageException - - if getattr(FileDownloader.__init__, "_esphome_patched", False): - return - - original_init = FileDownloader.__init__ - - def patched_init(self, *args: Any, **kwargs: Any) -> None: - max_retries = 5 - - for attempt in range(max_retries): - try: - original_init(self, *args, **kwargs) - return - except PackageException as e: - if attempt < max_retries - 1: - # Exponential backoff: 2, 4, 8, 16 seconds - delay = 2 ** (attempt + 1) - _LOGGER.warning( - "Package download failed: %s. " - "Retrying in %d seconds... (attempt %d/%d)", - str(e), - delay, - attempt + 1, - max_retries, - ) - # Close the response and session to free resources - # and force a new TCP connection on retry, which may - # route to a different CDN edge node - # pylint: disable=protected-access,broad-except - try: - if ( - hasattr(self, "_http_response") - and self._http_response is not None - ): - self._http_response.close() - if hasattr(self, "_http_session"): - self._http_session.close() - except Exception: - pass - # pylint: enable=protected-access,broad-except - time.sleep(delay) - else: - # Final attempt - re-raise - raise - - patched_init._esphome_patched = True # type: ignore[attr-defined] # pylint: disable=protected-access - FileDownloader.__init__ = patched_init - - IGNORE_LIB_WARNINGS = f"(?:{'|'.join(['Hash', 'Update'])})" FILTER_PLATFORMIO_LINES = [ r"Verbose mode can be enabled via `-v, --verbose` option.*", @@ -142,20 +53,6 @@ FILTER_PLATFORMIO_LINES = [ ] -class PlatformioLogFilter(logging.Filter): - """Filter to suppress noisy platformio log messages.""" - - _PATTERN = re.compile( - r"|".join(r"(?:" + pattern + r")" for pattern in FILTER_PLATFORMIO_LINES) - ) - - def filter(self, record: logging.LogRecord) -> bool: - # Only filter messages from platformio-related loggers - if "platformio" not in record.name.lower(): - return True - return self._PATTERN.match(record.getMessage()) is None - - 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()) @@ -166,30 +63,12 @@ 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 = ["platformio"] + list(args) + cmd = [sys.executable, "-m", "esphome.platformio_runner"] + list(args) if not CORE.verbose: kwargs["filter_lines"] = FILTER_PLATFORMIO_LINES - if os.environ.get("ESPHOME_USE_SUBPROCESS") is not None: - return run_external_process(*cmd, **kwargs) - - import platformio.__main__ - - patch_structhash() - patch_file_downloader() - - # Add log filter to suppress noisy platformio messages - log_filter = PlatformioLogFilter() if not CORE.verbose else None - if log_filter: - for handler in logging.getLogger().handlers: - handler.addFilter(log_filter) - try: - return run_external_command(platformio.__main__.main, *cmd, **kwargs) - finally: - if log_filter: - for handler in logging.getLogger().handlers: - handler.removeFilter(log_filter) + return run_external_process(*cmd, **kwargs) def run_platformio_cli_run(config, verbose, *args, **kwargs) -> str | int: diff --git a/esphome/platformio_runner.py b/esphome/platformio_runner.py new file mode 100644 index 0000000000..408d49d1a6 --- /dev/null +++ b/esphome/platformio_runner.py @@ -0,0 +1,114 @@ +"""Subprocess entry point that applies ESPHome's PlatformIO patches. + +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 +global state from leaking into the ESPHome process. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +import sys +import time +from typing import Any + +_LOGGER = logging.getLogger(__name__) + + +def patch_structhash() -> None: + """Avoid full rebuilds when files are added or removed. + + PlatformIO clears the build dir whenever its structure hash changes. + We replace that with an mtime check against ``platformio.ini`` so + incremental builds are preserved unless the project config changed. + """ + from platformio.run import cli, helpers + + def patched_clean_build_dir(build_dir, *_args): + from platformio import fs + from platformio.project.helpers import get_project_dir + + platformio_ini = Path(get_project_dir()) / "platformio.ini" + build_dir = Path(build_dir) + + if ( + build_dir.is_dir() + and platformio_ini.stat().st_mtime > build_dir.stat().st_mtime + ): + fs.rmtree(build_dir) + + if not build_dir.is_dir(): + build_dir.mkdir(parents=True) + + helpers.clean_build_dir = patched_clean_build_dir + cli.clean_build_dir = patched_clean_build_dir + + +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. + """ + from platformio.package.download import FileDownloader + from platformio.package.exception import PackageException + + if getattr(FileDownloader.__init__, "_esphome_patched", False): + return + + original_init = FileDownloader.__init__ + + def patched_init(self, *args: Any, **kwargs: Any) -> None: + max_retries = 5 + + for attempt in range(max_retries): + try: + original_init(self, *args, **kwargs) + return + except PackageException as e: + if attempt < max_retries - 1: + delay = 2 ** (attempt + 1) + _LOGGER.warning( + "Package download failed: %s. " + "Retrying in %d seconds... (attempt %d/%d)", + str(e), + delay, + attempt + 1, + max_retries, + ) + # pylint: disable=protected-access,broad-except + try: + if ( + hasattr(self, "_http_response") + and self._http_response is not None + ): + self._http_response.close() + if hasattr(self, "_http_session"): + self._http_session.close() + except Exception: + pass + # pylint: enable=protected-access,broad-except + time.sleep(delay) + else: + raise + + patched_init._esphome_patched = True # type: ignore[attr-defined] # pylint: disable=protected-access + FileDownloader.__init__ = patched_init + + +def main() -> int: + patch_structhash() + patch_file_downloader() + + import platformio.__main__ + + return platformio.__main__.main() or 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/platformio.ini b/platformio.ini index e0f7c7d443..7d17628a8f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -133,10 +133,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/releases/download/55.03.37/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.38/platform-espressif32.zip platform_packages = - pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.7/esp32-core-3.3.7.tar.xz - pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.3.1/esp-idf-v5.5.3.1.tar.xz + 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 framework = arduino, espidf ; Arduino as an ESP-IDF component lib_deps = @@ -169,9 +169,9 @@ 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.37/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.38/platform-espressif32.zip platform_packages = - pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.3.1/esp-idf-v5.5.3.1.tar.xz + 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 = diff --git a/pyproject.toml b/pyproject.toml index 2e3a247768..a744286e88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,6 @@ classifiers = [ "Topic :: Home Automation", ] -# Python 3.14 is not supported on Windows, see https://github.com/zephyrproject-rtos/windows-curses/issues/76 requires-python = ">=3.11.0,<3.15" dynamic = ["dependencies", "optional-dependencies", "version"] diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 1a1bfffd03..dfd4305c4d 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -84,9 +84,9 @@ def mock_decode_pc() -> Generator[Mock, None, None]: @pytest.fixture -def mock_run_external_command() -> Generator[Mock, None, None]: - """Mock run_external_command for platformio_api.""" - with patch("esphome.platformio_api.run_external_command") as mock: +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: yield mock diff --git a/tests/unit_tests/test_platformio_api.py b/tests/unit_tests/test_platformio_api.py index e1b3908c24..ddc4e45c84 100644 --- a/tests/unit_tests/test_platformio_api.py +++ b/tests/unit_tests/test_platformio_api.py @@ -1,7 +1,8 @@ """Tests for platformio_api.py path functions.""" +# pylint: disable=protected-access + import json -import logging import os from pathlib import Path import shutil @@ -10,7 +11,7 @@ from unittest.mock import MagicMock, Mock, call, patch import pytest -from esphome import platformio_api +from esphome import platformio_api, platformio_runner from esphome.core import CORE, EsphomeError @@ -281,13 +282,13 @@ def test_run_idedata_raises_on_invalid_json( def test_run_platformio_cli_sets_environment_variables( - setup_core: Path, mock_run_external_command: Mock + setup_core: Path, mock_run_external_process: Mock ) -> None: """Test run_platformio_cli sets correct environment variables.""" CORE.build_path = str(setup_core / "build" / "test") with patch.dict(os.environ, {}, clear=False): - mock_run_external_command.return_value = 0 + mock_run_external_process.return_value = 0 platformio_api.run_platformio_cli("test", "arg") # Check environment variables were set @@ -300,10 +301,12 @@ def test_run_platformio_cli_sets_environment_variables( assert "PLATFORMIO_LIBDEPS_DIR" in os.environ assert "PYTHONWARNINGS" in os.environ - # Check command was called correctly - mock_run_external_command.assert_called_once() - args = mock_run_external_command.call_args[0] - assert "platformio" in args + # Check command was called correctly — runs PlatformIO as a subprocess + # 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 "test" in args assert "arg" in args @@ -444,7 +447,7 @@ def test_patch_structhash(setup_core: Path) -> None: }, ): # Call patch_structhash - platformio_api.patch_structhash() + platformio_runner.patch_structhash() # Verify both modules had clean_build_dir patched # Check that clean_build_dir was set on both modules @@ -496,7 +499,7 @@ def test_patched_clean_build_dir_removes_outdated(setup_core: Path) -> None: }, ): # Call patch_structhash to install the patched function - platformio_api.patch_structhash() + platformio_runner.patch_structhash() # Call the patched function mock_helpers.clean_build_dir(str(build_dir), []) @@ -546,7 +549,7 @@ def test_patched_clean_build_dir_keeps_updated(setup_core: Path) -> None: }, ): # Call patch_structhash to install the patched function - platformio_api.patch_structhash() + platformio_runner.patch_structhash() # Call the patched function mock_helpers.clean_build_dir(str(build_dir), []) @@ -594,7 +597,7 @@ def test_patched_clean_build_dir_creates_missing(setup_core: Path) -> None: }, ): # Call patch_structhash to install the patched function - platformio_api.patch_structhash() + platformio_runner.patch_structhash() # Call the patched function mock_helpers.clean_build_dir(str(build_dir), []) @@ -719,7 +722,7 @@ def test_patch_file_downloader_succeeds_first_try() -> None: ), }, ): - platformio_api.patch_file_downloader() + platformio_runner.patch_file_downloader() from platformio.package.download import FileDownloader @@ -758,7 +761,7 @@ def test_patch_file_downloader_retries_on_failure() -> None: ), patch("time.sleep") as mock_sleep, ): - platformio_api.patch_file_downloader() + platformio_runner.patch_file_downloader() from platformio.package.download import FileDownloader @@ -799,7 +802,7 @@ def test_patch_file_downloader_raises_after_max_retries() -> None: ), patch("time.sleep") as mock_sleep, ): - platformio_api.patch_file_downloader() + platformio_runner.patch_file_downloader() from platformio.package.download import FileDownloader @@ -847,7 +850,7 @@ def test_patch_file_downloader_closes_session_and_response_between_retries() -> ), patch("time.sleep"), ): - platformio_api.patch_file_downloader() + platformio_runner.patch_file_downloader() from platformio.package.download import FileDownloader @@ -882,9 +885,9 @@ def test_patch_file_downloader_idempotent() -> None: }, ): # Patch multiple times - platformio_api.patch_file_downloader() - platformio_api.patch_file_downloader() - platformio_api.patch_file_downloader() + platformio_runner.patch_file_downloader() + platformio_runner.patch_file_downloader() + platformio_runner.patch_file_downloader() from platformio.package.download import FileDownloader @@ -895,19 +898,18 @@ def test_patch_file_downloader_idempotent() -> None: assert call_count == 1 -def test_platformio_log_filter_allows_non_platformio_messages() -> None: - """Test that non-platformio logger messages are allowed through.""" - log_filter = platformio_api.PlatformioLogFilter() - record = logging.LogRecord( - name="esphome.core", - level=logging.INFO, - pathname="", - lineno=0, - msg="Some esphome message", - args=(), - exc_info=None, +def _filter_through_redirect(line: str) -> str: + """Write a line through RedirectText with FILTER_PLATFORMIO_LINES and return what passes.""" + import io + + from esphome.util import RedirectText + + captured = io.StringIO() + redirect = RedirectText( + captured, filter_lines=platformio_api.FILTER_PLATFORMIO_LINES ) - assert log_filter.filter(record) is True + redirect.write(line + "\n") + return captured.getvalue() @pytest.mark.parametrize( @@ -930,19 +932,9 @@ def test_platformio_log_filter_allows_non_platformio_messages() -> None: "Memory Usage -> https://bit.ly/pio-memory-usage", ], ) -def test_platformio_log_filter_blocks_noisy_messages(msg: str) -> None: - """Test that noisy platformio messages are filtered out.""" - log_filter = platformio_api.PlatformioLogFilter() - record = logging.LogRecord( - name="platformio.builder", - level=logging.INFO, - pathname="", - lineno=0, - msg=msg, - args=(), - exc_info=None, - ) - assert log_filter.filter(record) is False +def test_filter_platformio_lines_blocks_noisy_messages(msg: str) -> None: + """Test that noisy platformio output lines are filtered out by RedirectText.""" + assert _filter_through_redirect(msg) == "" @pytest.mark.parametrize( @@ -954,39 +946,6 @@ def test_platformio_log_filter_blocks_noisy_messages(msg: str) -> None: "warning: unused variable", ], ) -def test_platformio_log_filter_allows_other_platformio_messages(msg: str) -> None: - """Test that non-noisy platformio messages are allowed through.""" - log_filter = platformio_api.PlatformioLogFilter() - record = logging.LogRecord( - name="platformio.builder", - level=logging.INFO, - pathname="", - lineno=0, - msg=msg, - args=(), - exc_info=None, - ) - assert log_filter.filter(record) is True - - -@pytest.mark.parametrize( - "logger_name", - [ - "PLATFORMIO.builder", - "PlatformIO.core", - "platformio.run", - ], -) -def test_platformio_log_filter_case_insensitive_logger_name(logger_name: str) -> None: - """Test that platformio logger name matching is case insensitive.""" - log_filter = platformio_api.PlatformioLogFilter() - record = logging.LogRecord( - name=logger_name, - level=logging.INFO, - pathname="", - lineno=0, - msg="Found 5 compatible libraries", - args=(), - exc_info=None, - ) - assert log_filter.filter(record) is False +def test_filter_platformio_lines_allows_other_messages(msg: str) -> None: + """Test that non-noisy platformio output lines pass through RedirectText.""" + assert _filter_through_redirect(msg) == msg + "\n" From 6d92cc3d2b8d24845587d6f551d9fb1fa7169010 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Apr 2026 10:24:23 -1000 Subject: [PATCH 036/575] [packages] Fix false deprecation warning and wrong error paths in nested packages (#15605) --- esphome/components/packages/__init__.py | 34 +++- .../component_tests/packages/test_packages.py | 177 +++++++++++++++++- 2 files changed, 205 insertions(+), 6 deletions(-) diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 04db690c6f..3a15b5b95a 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -45,6 +45,18 @@ 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. @@ -318,11 +330,11 @@ def _walk_packages( if not isinstance(packages, dict): _walk_package_list(packages, callback, context) elif (result := _walk_package_dict(packages, callback, context)) is not None: - if not validate_deprecated: + 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. - # Note: this catches *any* cv.Invalid from the callback, which may - # mask real validation errors in named package dicts. # This block can be removed once the single-package # deprecation period (2026.7.0) is over. config[CONF_PACKAGES] = [packages] @@ -461,6 +473,9 @@ class _PackageProcessor: self, package_config: dict | str, context_vars: ContextVars | None ) -> 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) self.collect_substitutions(package_config) @@ -470,7 +485,18 @@ class _PackageProcessor: # Push context from !include vars on the package root and on the packages key context_vars = push_context(package_config, context_vars) context_vars = push_context(package_config[CONF_PACKAGES], context_vars) - return _walk_packages(package_config, self.process_package, 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, + ) def do_packages_pass( diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index 0893c7dcbb..0b828d757e 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -1,11 +1,18 @@ """Tests for the packages component.""" +import logging from pathlib import Path from unittest.mock import MagicMock, patch import pytest -from esphome.components.packages import CONFIG_SCHEMA, do_packages_pass, merge_packages +from esphome.components.packages import ( + CONFIG_SCHEMA, + _walk_packages, + do_packages_pass, + is_package_definition, + merge_packages, +) from esphome.components.substitutions import do_substitution_pass import esphome.config as config_module from esphome.config import resolve_extend_remove @@ -37,7 +44,7 @@ from esphome.const import ( ) from esphome.core import CORE from esphome.util import OrderedDict -from esphome.yaml_util import add_context +from esphome.yaml_util import IncludeFile, add_context # Test strings TEST_DEVICE_NAME = "test_device_name" @@ -79,6 +86,44 @@ 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. @@ -1107,6 +1152,134 @@ def test_invalid_package_contents_masked_by_deprecation( do_packages_pass(config) +def test_named_dict_with_include_files_no_false_deprecation_warning( + caplog: pytest.LogCaptureFixture, +) -> None: + """Package errors in named dicts must not trigger the deprecated fallback.""" + good_include = MagicMock(spec=IncludeFile) + bad_include = MagicMock(spec=IncludeFile) + + config = { + CONF_PACKAGES: { + "good_pkg": good_include, + "bad_pkg": bad_include, + }, + } + + call_count = 0 + + def failing_callback(package_config: dict, context: object) -> dict: + nonlocal call_count + call_count += 1 + if call_count == 1: + # First package processes fine + return {CONF_WIFI: {CONF_SSID: "test"}} + # Second package has an error (e.g. jinja syntax error) + raise cv.Invalid("simulated jinja error in bad_pkg") + + with ( + caplog.at_level(logging.WARNING), + pytest.raises(cv.Invalid, match="simulated jinja error"), + ): + _walk_packages(config, failing_callback) + + # Must NOT emit the deprecated single-package warning + assert "deprecated" not in caplog.text.lower() + + +def test_validate_deprecated_false_raises_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. + """ + config = { + CONF_PACKAGES: { + "pkg_a": {CONF_WIFI: {CONF_SSID: "test"}}, + "pkg_b": {CONF_WIFI: {CONF_SSID: "test2"}}, + }, + } + + call_count = 0 + + def failing_callback(package_config: dict, context: object) -> dict: + nonlocal call_count + call_count += 1 + if call_count == 1: + return package_config + raise cv.Invalid("nested error") + + with ( + caplog.at_level(logging.WARNING), + pytest.raises(cv.Invalid, match="nested error"), + ): + _walk_packages(config, failing_callback, validate_deprecated=False) + + assert "deprecated" not in caplog.text.lower() + + +def test_error_on_first_declared_package_still_detected() -> None: + """When the first declared package errors, it's the last processed in reverse. + + All other entries are already resolved to dicts, but the failing entry + retains its original IncludeFile value since assignment was skipped. + """ + config = { + CONF_PACKAGES: { + "first_pkg": MagicMock(spec=IncludeFile), + "second_pkg": MagicMock(spec=IncludeFile), + "third_pkg": MagicMock(spec=IncludeFile), + }, + } + + call_count = 0 + + def fail_on_last(package_config: dict, context: object) -> dict: + nonlocal call_count + call_count += 1 + # Reverse iteration: third_pkg (1), second_pkg (2), first_pkg (3) + if call_count < 3: + return {CONF_WIFI: {CONF_SSID: "test"}} + raise cv.Invalid("error in first_pkg") + + with pytest.raises(cv.Invalid, match="error in first_pkg"): + _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) -> 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 8754bbfa89f241f15bb239066ef98c9dbde61e50 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Mon, 13 Apr 2026 06:29:11 +1000 Subject: [PATCH 037/575] [lvgl] Fix use of rotation on host SDL (#15611) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/lvgl/__init__.py | 2 +- tests/components/lvgl/test.host.yaml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index f6f6204f4c..b6421dc43d 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -341,7 +341,7 @@ async def to_code(configs): df.LOGGER.info("LVGL will use hardware rotation via display driver") else: rotation_type = RotationType.ROTATION_SOFTWARE - if get_esp32_variant() == VARIANT_ESP32P4: + if CORE.is_esp32 and get_esp32_variant() == VARIANT_ESP32P4: df.LOGGER.info("LVGL will use software rotation (PPA accelerated)") else: df.LOGGER.info("LVGL will use software rotation") diff --git a/tests/components/lvgl/test.host.yaml b/tests/components/lvgl/test.host.yaml index f84156c9d8..6328648fe3 100644 --- a/tests/components/lvgl/test.host.yaml +++ b/tests/components/lvgl/test.host.yaml @@ -20,6 +20,7 @@ lvgl: - id: lvgl_0 default_font: space16 displays: sdl0 + rotation: 180 top_layer: - id: lvgl_1 From daa68a2a603b8954974f331ef619adcbdfead8d4 Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Sun, 12 Apr 2026 23:48:30 +0200 Subject: [PATCH 038/575] [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 5608aa10a51842d4234a040d2d2519dd90dc706f Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:46:49 +1200 Subject: [PATCH 039/575] [CI] Don't run label workflow on closed/merged PRs (#15678) --- .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 27ddfe5911..f5214a5ce0 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -20,7 +20,7 @@ env: jobs: label: runs-on: ubuntu-latest - if: github.event.action != 'labeled' || github.event.sender.type != 'Bot' + 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 From 41c9ed28cd2c8ebc9712c670f8a1f0b34b808c25 Mon Sep 17 00:00:00 2001 From: schrob <83939986+schdro@users.noreply.github.com> Date: Mon, 13 Apr 2026 01:23:01 +0200 Subject: [PATCH 040/575] [esp32] Use static stack memory for loop task instead of heap (#15659) Co-authored-by: J. Nick Koston --- 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 ac8a2467a5004908ed06b13f1bbc5a3c30341f89 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 041/575] [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 d4e9c62d92d24054c57a5915570f90b3b9aedade 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 042/575] 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 1466eccc9b..fa642b579b 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 c62a75ee17e0ef038e0dc32e7dc097a1a446e755 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Apr 2026 20:40:33 -1000 Subject: [PATCH 043/575] [benchmark] Use -Os to match firmware optimization level (#15688) --- script/cpp_benchmark.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/script/cpp_benchmark.py b/script/cpp_benchmark.py index 92faa05819..5080a9fec7 100755 --- a/script/cpp_benchmark.py +++ b/script/cpp_benchmark.py @@ -26,12 +26,11 @@ 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) + "-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 4f69c3b850ebb7943b1599881f13c0255f078da7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Apr 2026 21:03:53 -1000 Subject: [PATCH 044/575] [benchmark] Add SubscribeLogsResponse encode benchmarks (#15696) --- .../components/api/bench_log_response.cpp | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 tests/benchmarks/components/api/bench_log_response.cpp diff --git a/tests/benchmarks/components/api/bench_log_response.cpp b/tests/benchmarks/components/api/bench_log_response.cpp new file mode 100644 index 0000000000..4ef57987be --- /dev/null +++ b/tests/benchmarks/components/api/bench_log_response.cpp @@ -0,0 +1,118 @@ +#include + +#include "esphome/components/api/api_pb2.h" +#include "esphome/components/api/api_buffer.h" + +namespace esphome::api::benchmarks { + +// Inner iteration count to amortize CodSpeed instrumentation overhead. +static constexpr int kInnerIterations = 2000; + +// Typical log line: "[12:34:56][D][sensor:094]: 'Temperature': Sending state 23.50000 with 1 decimals of accuracy" +static constexpr const char *kTypicalLogLine = + "[12:34:56][D][sensor:094]: 'Temperature': Sending state 23.50000 with 1 decimals of accuracy"; + +// Short log line: "[12:34:56][I][app:029]: Running..." +static constexpr const char *kShortLogLine = "[12:34:56][I][app:029]: Running..."; + +// --- Encode --- + +static void Encode_LogResponse_Typical(benchmark::State &state) { + APIBuffer buffer; + SubscribeLogsResponse msg; + msg.level = enums::LOG_LEVEL_DEBUG; + msg.set_message(reinterpret_cast(kTypicalLogLine), strlen(kTypicalLogLine)); + uint32_t size = msg.calculate_size(); + buffer.resize(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_LogResponse_Typical); + +static void Encode_LogResponse_Short(benchmark::State &state) { + APIBuffer buffer; + SubscribeLogsResponse msg; + msg.level = enums::LOG_LEVEL_INFO; + msg.set_message(reinterpret_cast(kShortLogLine), strlen(kShortLogLine)); + uint32_t size = msg.calculate_size(); + buffer.resize(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_LogResponse_Short); + +// --- Calculate Size --- + +static void CalculateSize_LogResponse_Typical(benchmark::State &state) { + SubscribeLogsResponse msg; + msg.level = enums::LOG_LEVEL_DEBUG; + msg.set_message(reinterpret_cast(kTypicalLogLine), strlen(kTypicalLogLine)); + + 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_LogResponse_Typical); + +// --- Calc + Encode (steady state) --- + +static void CalcAndEncode_LogResponse_Typical(benchmark::State &state) { + APIBuffer buffer; + SubscribeLogsResponse msg; + msg.level = enums::LOG_LEVEL_DEBUG; + msg.set_message(reinterpret_cast(kTypicalLogLine), strlen(kTypicalLogLine)); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + uint32_t size = msg.calculate_size(); + buffer.resize(size); + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + } + benchmark::DoNotOptimize(buffer.data()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalcAndEncode_LogResponse_Typical); + +// --- Calc + Encode (fresh allocation each time) --- + +static void CalcAndEncode_LogResponse_Typical_Fresh(benchmark::State &state) { + SubscribeLogsResponse msg; + msg.level = enums::LOG_LEVEL_DEBUG; + msg.set_message(reinterpret_cast(kTypicalLogLine), strlen(kTypicalLogLine)); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + APIBuffer buffer; + uint32_t size = msg.calculate_size(); + buffer.resize(size); + ProtoWriteBuffer writer(&buffer, 0); + msg.encode(writer); + benchmark::DoNotOptimize(buffer.data()); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(CalcAndEncode_LogResponse_Typical_Fresh); + +} // namespace esphome::api::benchmarks From 5b4385a08497b5f2eda32dea77e206d99e5d3f78 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Apr 2026 21:42:31 -1000 Subject: [PATCH 045/575] [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 6db787d5e4cf637182b7b82e1e23b82971a1a46a 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 046/575] 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 fa642b579b..cd3aa5bd86 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 fe6ecb24b4bfc16f096837e39a74664c97e382de Mon Sep 17 00:00:00 2001 From: Diorcet Yann Date: Mon, 13 Apr 2026 13:49:13 +0200 Subject: [PATCH 047/575] [bme68x_bsec2] use esphome-libs wrappers for ESP32 (#15697) --- esphome/components/bme68x_bsec2/__init__.py | 36 +++++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/esphome/components/bme68x_bsec2/__init__.py b/esphome/components/bme68x_bsec2/__init__.py index 5f0afa9c9f..b63443c5f3 100644 --- a/esphome/components/bme68x_bsec2/__init__.py +++ b/esphome/components/bme68x_bsec2/__init__.py @@ -17,6 +17,7 @@ CODEOWNERS = ["@neffs", "@kbx81"] DOMAIN = "bme68x_bsec2" BSEC2_LIBRARY_VERSION = "1.10.2610" +BME68x_LIBRARY_VERSION = "v1.3.40408" CONF_ALGORITHM_OUTPUT = "algorithm_output" CONF_BME68X_BSEC2_ID = "bme68x_bsec2_id" @@ -184,16 +185,31 @@ async def to_code_base(config): if core.CORE.using_arduino: cg.add_library("Wire", None) cg.add_library("SPI", None) - cg.add_library( - "BME68x Sensor library", - None, - "https://github.com/boschsensortec/Bosch-BME68x-Library#v1.3.40408", - ) - cg.add_library( - "BSEC2 Software Library", - None, - f"https://github.com/boschsensortec/Bosch-BSEC2-Library.git#{BSEC2_LIBRARY_VERSION}", - ) + + if core.CORE.is_esp32: + from esphome.components.esp32 import add_idf_component + + add_idf_component( + name="boschsensortec/Bosch-BME68x-Library", + repo="https://github.com/esphome-libs/Bosch-BME68x-Library", + ref=BME68x_LIBRARY_VERSION, + ) + add_idf_component( + name="boschsensortec/Bosch-BSEC2-Library", + repo="https://github.com/esphome-libs/Bosch-BSEC2-Library", + ref=BSEC2_LIBRARY_VERSION, + ) + else: + cg.add_library( + "BME68x Sensor library", + None, + f"https://github.com/boschsensortec/Bosch-BME68x-Library#{BME68x_LIBRARY_VERSION}", + ) + cg.add_library( + "BSEC2 Software Library", + None, + f"https://github.com/boschsensortec/Bosch-BSEC2-Library.git#{BSEC2_LIBRARY_VERSION}", + ) cg.add_define("USE_BSEC2") From 7918a93a7feeb37f8cb957444e326e4e9867f970 Mon Sep 17 00:00:00 2001 From: Diorcet Yann Date: Mon, 13 Apr 2026 15:40:49 +0200 Subject: [PATCH 048/575] [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 6aa538a61d7559531b446763abae20eda918572b Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 13 Apr 2026 15:42:02 +0200 Subject: [PATCH 049/575] [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 f30f0a0edcbc7ed89fff03f10b14f90326d0bbab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 03:43:17 -1000 Subject: [PATCH 050/575] [zephyr] Remove redundant yield() from main loop (#15694) --- esphome/components/zephyr/core.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/esphome/components/zephyr/core.cpp b/esphome/components/zephyr/core.cpp index a3b0471ebc..93a9a1ae8e 100644 --- a/esphome/components/zephyr/core.cpp +++ b/esphome/components/zephyr/core.cpp @@ -99,7 +99,6 @@ int main() { setup(); while (true) { loop(); - esphome::yield(); } return 0; } From 5d0cfc31fa92c1038b328419f61d33f1eaadbe68 Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:18:44 -0400 Subject: [PATCH 051/575] [core] Move FILTER_PLATFORMIO_LINES into platformio_runner (#15707) --- esphome/platformio_api.py | 39 --------------------- esphome/platformio_runner.py | 45 ++++++++++++++++++++++++- tests/unit_tests/test_platformio_api.py | 2 +- 3 files changed, 45 insertions(+), 41 deletions(-) diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index fc21977fdd..dec541985f 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio_api.py @@ -14,45 +14,6 @@ from esphome.util import run_external_process _LOGGER = logging.getLogger(__name__) -IGNORE_LIB_WARNINGS = f"(?:{'|'.join(['Hash', 'Update'])})" -FILTER_PLATFORMIO_LINES = [ - r"Verbose mode can be enabled via `-v, --verbose` option.*", - r"CONFIGURATION: https://docs.platformio.org/.*", - r"DEBUG: Current.*", - r"LDF Modes:.*", - r"LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf.*", - f"Looking for {IGNORE_LIB_WARNINGS} library in registry", - f"Warning! Library `.*'{IGNORE_LIB_WARNINGS}.*` has not been found in PlatformIO Registry.", - f"You can ignore this message, if `.*{IGNORE_LIB_WARNINGS}.*` is a built-in library.*", - r"Scanning dependencies...", - r"Found \d+ compatible libraries", - r"Memory Usage -> https://bit.ly/pio-memory-usage", - r"Found: https://platformio.org/lib/show/.*", - r"Using cache: .*", - r"Installing dependencies", - r"Library Manager: Already installed, built-in library", - r"Building in .* mode", - r"Advanced Memory Usage is available via .*", - r"Merged .* ELF section", - r"esptool.py v.*", - r"esptool v.*", - r"Checking size .*", - r"Retrieving maximum program size .*", - r"PLATFORM: .*", - r"PACKAGES:.*", - r" - framework-arduinoespressif.* \(.*\)", - r" - tool-esptool.* \(.*\)", - r" - toolchain-.* \(.*\)", - r"Creating BIN file .*", - r"Warning! Could not find file \".*.crt\"", - r"Warning! Arduino framework as an ESP-IDF component doesn't handle the `variant` field! The default `esp32` variant will be used.", - r"Warning: DEPRECATED: 'esptool.py' is deprecated. Please use 'esptool' instead. The '.py' suffix will be removed in a future major release.", - r"Warning: esp-idf-size exited with code 2", - r"esp_idf_size: error: unrecognized arguments: --ng", - r"Package configuration completed successfully", -] - - 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()) diff --git a/esphome/platformio_runner.py b/esphome/platformio_runner.py index 92700d5c42..599c9408a4 100644 --- a/esphome/platformio_runner.py +++ b/esphome/platformio_runner.py @@ -101,6 +101,50 @@ def patch_file_downloader() -> None: FileDownloader.__init__ = patched_init +_IGNORE_LIB_WARNINGS = f"(?:{'|'.join(['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 +# ``re.match``). Disabled when the user passes ``-v`` / ``--verbose`` to +# ``esphome compile``. +FILTER_PLATFORMIO_LINES = [ + r"Verbose mode can be enabled via `-v, --verbose` option.*", + r"CONFIGURATION: https://docs.platformio.org/.*", + r"DEBUG: Current.*", + r"LDF Modes:.*", + r"LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf.*", + f"Looking for {_IGNORE_LIB_WARNINGS} library in registry", + f"Warning! Library `.*'{_IGNORE_LIB_WARNINGS}.*` has not been found in PlatformIO Registry.", + f"You can ignore this message, if `.*{_IGNORE_LIB_WARNINGS}.*` is a built-in library.*", + r"Scanning dependencies...", + r"Found \d+ compatible libraries", + r"Memory Usage -> https://bit.ly/pio-memory-usage", + r"Found: https://platformio.org/lib/show/.*", + r"Using cache: .*", + r"Installing dependencies", + r"Library Manager: Already installed, built-in library", + r"Building in .* mode", + r"Advanced Memory Usage is available via .*", + r"Merged .* ELF section", + r"esptool.py v.*", + r"esptool v.*", + r"Checking size .*", + r"Retrieving maximum program size .*", + r"PLATFORM: .*", + r"PACKAGES:.*", + r" - framework-arduinoespressif.* \(.*\)", + r" - tool-esptool.* \(.*\)", + r" - toolchain-.* \(.*\)", + r"Creating BIN file .*", + r"Warning! Could not find file \".*.crt\"", + r"Warning! Arduino framework as an ESP-IDF component doesn't handle the `variant` field! The default `esp32` variant will be used.", + r"Warning: DEPRECATED: 'esptool.py' is deprecated. Please use 'esptool' instead. The '.py' suffix will be removed in a future major release.", + r"Warning: esp-idf-size exited with code 2", + r"esp_idf_size: error: unrecognized arguments: --ng", + r"Package configuration completed successfully", +] + + def main() -> int: patch_structhash() patch_file_downloader() @@ -126,7 +170,6 @@ def main() -> int: # 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:]) diff --git a/tests/unit_tests/test_platformio_api.py b/tests/unit_tests/test_platformio_api.py index ddc4e45c84..67e64e5f61 100644 --- a/tests/unit_tests/test_platformio_api.py +++ b/tests/unit_tests/test_platformio_api.py @@ -906,7 +906,7 @@ def _filter_through_redirect(line: str) -> str: captured = io.StringIO() redirect = RedirectText( - captured, filter_lines=platformio_api.FILTER_PLATFORMIO_LINES + captured, filter_lines=platformio_runner.FILTER_PLATFORMIO_LINES ) redirect.write(line + "\n") return captured.getvalue() From fb0283e0ee04830275fe6e857f4a8955ac137c7b 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 052/575] [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 53ce2a2f7fed1a142f43eec73c681c0106d14552 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 08:25:05 -1000 Subject: [PATCH 053/575] [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 af7cb1d81ecefbdc8240a03f05ac926c1d21a96b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 08:40:25 -1000 Subject: [PATCH 054/575] [scheduler] Force-inline process_defer_queue_() fast path (#15686) --- 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 9f7e310526cb51126aaef85e64f76b8c152e620c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 08:40:39 -1000 Subject: [PATCH 055/575] [scheduler] Force-inline cleanup_() fast path (#15683) --- 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 ea6c2a675e..e678ea0987 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 b85a7ef3177d2fe171922e2f67ad3056b4b3f34f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 08:40:58 -1000 Subject: [PATCH 056/575] [scheduler] Force-inline process_to_add() fast path (#15685) --- 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 e678ea0987..21af94ea4e 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 e8bc4bedb4ebe191bb10a6f125979002d0935a14 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 11:00:11 -1000 Subject: [PATCH 057/575] Bump actions/cache from 5.0.4 to 5.0.5 in /.github/actions/restore-python (#15714) 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 af54175c01..21393f2aba 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@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv # yamllint disable-line rule:line-length From ce6bffb65cba8f3a9d954b00b2d4e77049343df0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 11:00:24 -1000 Subject: [PATCH 058/575] Bump actions/cache from 5.0.4 to 5.0.5 (#15713) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2240879bd2..6aa5b2a547 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv # yamllint disable-line rule:line-length @@ -159,7 +159,7 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} @@ -198,7 +198,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Restore components graph cache - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} @@ -231,7 +231,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@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} @@ -253,7 +253,7 @@ jobs: python-version: "3.13" - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} @@ -387,14 +387,14 @@ jobs: - name: Cache platformio if: github.ref == 'refs/heads/dev' - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + 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' - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} @@ -466,14 +466,14 @@ jobs: - name: Cache platformio if: github.ref == 'refs/heads/dev' - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + 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@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.platformio key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} @@ -555,14 +555,14 @@ jobs: - name: Cache platformio if: github.ref == 'refs/heads/dev' - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + 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@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.platformio key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} @@ -817,7 +817,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@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: memory-analysis-target.json key: ${{ steps.cache-key.outputs.cache-key }} @@ -841,7 +841,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@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.platformio key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} @@ -883,7 +883,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@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: memory-analysis-target.json key: ${{ steps.cache-key.outputs.cache-key }} @@ -930,7 +930,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache platformio - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.platformio key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} From 40348092818c1df90de5a7795b45787dce8e36c9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 11:00:46 -1000 Subject: [PATCH 059/575] Bump actions/create-github-app-token from 3.0.0 to 3.1.1 (#15712) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .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 f5214a5ce0..0e5ceb9346 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@f8d387b68d61c58ab83c6c016672934102569859 # v2 + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v2 with: app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c92581b49b..35b9e065e1 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@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + 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 }} @@ -256,7 +256,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + 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 }} @@ -287,7 +287,7 @@ jobs: steps: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + 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 8cdffef82a44ec58c43a290ca8b22f9e55071b8c Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:06:56 -0400 Subject: [PATCH 060/575] [heatpumpir] Bump tonia/HeatpumpIR to 1.0.41 (#15711) --- .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 60c3776aa8..ab526134f8 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -10c432ae818f9ed7fd4a0176a04467b1f2634363f5ec985045a6d72747f60b90 +dc8ad5472d9fb44ce1ca29a0601afd65705642799a2819704dfc8459fbaf9815 diff --git a/esphome/components/heatpumpir/climate.py b/esphome/components/heatpumpir/climate.py index 7743da77ab..b7e0437480 100644 --- a/esphome/components/heatpumpir/climate.py +++ b/esphome/components/heatpumpir/climate.py @@ -127,6 +127,6 @@ async def to_code(config): 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.40") + cg.add_library("tonia/HeatpumpIR", "1.0.41") if CORE.is_libretiny or CORE.is_esp32: CORE.add_platformio_option("lib_ignore", ["IRremoteESP8266"]) diff --git a/platformio.ini b/platformio.ini index 708d62afdc..3897db83e1 100644 --- a/platformio.ini +++ b/platformio.ini @@ -83,7 +83,7 @@ lib_deps = fastled/FastLED@3.9.16 ; fastled_base freekode/TM1651@1.0.1 ; tm1651 dudanov/MideaUART@1.1.9 ; midea - tonia/HeatpumpIR@1.0.40 ; heatpumpir + tonia/HeatpumpIR@1.0.41 ; heatpumpir build_flags = ${common.build_flags} -DUSE_ARDUINO @@ -178,7 +178,7 @@ lib_deps = ${common:idf.lib_deps} droscy/esp_wireguard@0.4.4 ; wireguard kahrendt/ESPMicroSpeechFeatures@1.1.0 ; micro_wake_word - tonia/HeatpumpIR@1.0.40 ; heatpumpir + tonia/HeatpumpIR@1.0.41 ; heatpumpir build_flags = ${common:idf.build_flags} -Wno-nonnull-compare From 73c972a604e4ebb735b40d41f249dbd9deff0f9e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 13:59:32 -1000 Subject: [PATCH 061/575] [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 21df5d9bf6162f5d14529560f1c8b98c704a13f3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 13:59:45 -1000 Subject: [PATCH 062/575] [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 edb16a27d374c55c86e8679cae43a36afc343b6c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Apr 2026 16:58:48 -1000 Subject: [PATCH 063/575] [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 6b4b65346240c1cb93084141b28d07119fdc1d43 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 03:18:38 -1000 Subject: [PATCH 064/575] [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 2a530a4bf4618c08999db8b3fdfa52b1e2f09271 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 07:48:33 -1000 Subject: [PATCH 065/575] [core] Optimize format_hex_internal by splitting separator loop (#15594) --- esphome/core/helpers.cpp | 17 ++-- esphome/core/helpers.h | 6 +- tests/components/core/test_helpers.cpp | 120 +++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 10 deletions(-) create mode 100644 tests/components/core/test_helpers.cpp diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 5940f6ec98..cbe22dd09a 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -347,17 +347,18 @@ 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. +// 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'; + if (length == 0 || buffer_size == 0) { + if (buffer_size > 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); + size_t max_bytes = separator ? (buffer_size / 3) : ((buffer_size - 1) / 2); if (max_bytes == 0) { buffer[0] = '\0'; return buffer; @@ -369,10 +370,12 @@ static char *format_hex_internal(char *buffer, size_t buffer_size, const uint8_t 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) { buffer[pos + 2] = separator; } } + // With separator: overwrite last separator with '\0' + // Without: write '\0' after last hex char buffer[length * stride - (separator ? 1 : 0)] = '\0'; return buffer; } diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index c26bbe17b7..3c42d7df07 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1263,13 +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) -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 -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) -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. diff --git a/tests/components/core/test_helpers.cpp b/tests/components/core/test_helpers.cpp new file mode 100644 index 0000000000..00169621c3 --- /dev/null +++ b/tests/components/core/test_helpers.cpp @@ -0,0 +1,120 @@ +#include +#include + +#include "esphome/core/helpers.h" + +namespace esphome::core::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::core::testing From c833ff4a8480338355011792ffea6aa581b774e2 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 14 Apr 2026 13:49:18 -0400 Subject: [PATCH 066/575] [audio] Add/configure microDecoder library in preparation for use in future PRs (#15679) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/audio/__init__.py | 25 ++++++++++++++++++++++++- esphome/idf_component.yml | 2 ++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index 8f2102de6a..fee582ca25 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -1,7 +1,11 @@ from dataclasses import dataclass import esphome.codegen as cg -from esphome.components.esp32 import add_idf_component, include_builtin_idf_component +from esphome.components.esp32 import ( + add_idf_component, + add_idf_sdkconfig_option, + 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.core import CORE @@ -27,6 +31,7 @@ class AudioData: flac_support: bool = False mp3_support: bool = False opus_support: bool = False + micro_decoder_support: bool = False def _get_data() -> AudioData: @@ -50,6 +55,11 @@ def request_opus_support() -> None: _get_data().opus_support = True +def request_micro_decoder_support() -> None: + """Request micro-decoder library support for audio decoding.""" + _get_data().micro_decoder_support = True + + CONF_MIN_BITS_PER_SAMPLE = "min_bits_per_sample" CONF_MAX_BITS_PER_SAMPLE = "max_bits_per_sample" CONF_MIN_CHANNELS = "min_channels" @@ -208,6 +218,19 @@ async def to_code(config): ) data = _get_data() + + if data.micro_decoder_support: + add_idf_component(name="esphome/micro-decoder", ref="0.1.1") + + # 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: + add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_FLAC", False) + if not data.mp3_support: + 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) + + # Legacy audio_decoder.cpp support defines and components if data.flac_support: cg.add_define("USE_AUDIO_FLAC_SUPPORT") add_idf_component(name="esphome/micro-flac", ref="0.1.1") diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index bf42730e67..f4e3e751ec 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/micro-decoder: + version: 0.1.1 esphome/micro-flac: version: 0.1.1 esphome/micro-opus: From 5ba8c644e4f0d47ffcd524be544323ee92d2b343 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 07:49:27 -1000 Subject: [PATCH 067/575] [ld24xx] Replace heap-allocated SensorWithDedup with inline SensorWithDedup (#15676) --- esphome/components/ld2410/ld2410.cpp | 21 +++++++------ esphome/components/ld2410/ld2410.h | 4 +-- esphome/components/ld2412/ld2412.cpp | 29 +++++++++--------- esphome/components/ld2412/ld2412.h | 4 +-- esphome/components/ld2450/ld2450.cpp | 20 ++++++------- esphome/components/ld2450/ld2450.h | 18 ++++++------ esphome/components/ld24xx/ld24xx.h | 44 ++++++++++++++-------------- 7 files changed, 69 insertions(+), 71 deletions(-) diff --git a/esphome/components/ld2410/ld2410.cpp b/esphome/components/ld2410/ld2410.cpp index f10e7ec0aa..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 @@ -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..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.cpp b/esphome/components/ld2412/ld2412.cpp index 38e1a59aba..a502ae3c10 100644 --- a/esphome/components/ld2412/ld2412.cpp +++ b/esphome/components/ld2412/ld2412.cpp @@ -397,12 +397,12 @@ 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]) - if (this->detection_distance_sensor_ != nullptr) { + 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)) { 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 @@ -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... @@ -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..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.cpp b/esphome/components/ld2450/ld2450.cpp index 58c3cac42d..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 @@ -872,33 +873,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..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 fd55167974..cba1b68a15 100644 --- a/esphome/components/ld24xx/ld24xx.h +++ b/esphome/components/ld24xx/ld24xx.h @@ -11,28 +11,20 @@ #define SUB_SENSOR_WITH_DEDUP(name, dedup_type) \ protected: \ - ld24xx::SensorWithDedup *name##_sensor_{nullptr}; \ + ld24xx::SensorWithDedup 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); \ - } +#define SAFE_PUBLISH_SENSOR(sensor, value) (sensor).publish_state_if_not_dup(value) -#define SAFE_PUBLISH_SENSOR_UNKNOWN(sensor) \ - if ((sensor) != nullptr) { \ - (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) @@ -70,25 +62,33 @@ 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 +/// Sensor with deduplication — sensor may be null, null check is internal. +/// Stored inline, no heap allocation. Does nothing when no sensor is set. template class SensorWithDedup { public: - SensorWithDedup(sensor::Sensor *sens) : sens(sens) {} + void set_sensor(sensor::Sensor *sens) { + this->sens_ = sens; + this->dedup_ = {}; + } 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 cf01163c8cdc6ad7c8ad68b37c30804349eac499 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 07:49:44 -1000 Subject: [PATCH 068/575] [core] Add uint32_to_str helper and use in preferences (#15597) --- esphome/components/esp32/preferences.cpp | 12 +-- esphome/components/libretiny/preferences.cpp | 12 +-- esphome/core/helpers.cpp | 14 ++++ esphome/core/helpers.h | 15 ++++ tests/benchmarks/core/bench_helpers.cpp | 56 ++++++++++++++ tests/components/core/test_uint32_to_str.cpp | 77 ++++++++++++++++++++ 6 files changed, 170 insertions(+), 16 deletions(-) create mode 100644 tests/components/core/test_uint32_to_str.cpp diff --git a/esphome/components/esp32/preferences.cpp b/esphome/components/esp32/preferences.cpp index bc0a34ebe8..925c4e7662 100644 --- a/esphome/components/esp32/preferences.cpp +++ b/esphome/components/esp32/preferences.cpp @@ -4,7 +4,6 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" #include -#include #include #include @@ -12,9 +11,6 @@ namespace esphome::esp32 { static const char *const TAG = "preferences"; -// Buffer size for converting uint32_t to string: max "4294967295" (10 chars) + null terminator + 1 padding -static constexpr size_t KEY_BUFFER_SIZE = 12; - struct NVSData { uint32_t key; SmallInlineBuffer<8> data; // Most prefs fit in 8 bytes (covers fan, cover, select, etc.) @@ -51,8 +47,8 @@ bool ESP32PreferenceBackend::load(uint8_t *data, size_t len) { } } - char key_str[KEY_BUFFER_SIZE]; - snprintf(key_str, sizeof(key_str), "%" PRIu32, this->key); + char key_str[UINT32_MAX_STR_SIZE]; + uint32_to_str(key_str, this->key); size_t actual_len; esp_err_t err = nvs_get_blob(this->nvs_handle, key_str, nullptr, &actual_len); if (err != 0) { @@ -108,8 +104,8 @@ bool ESP32Preferences::sync() { uint32_t last_key = 0; for (const auto &save : s_pending_save) { - char key_str[KEY_BUFFER_SIZE]; - snprintf(key_str, sizeof(key_str), "%" PRIu32, save.key); + char key_str[UINT32_MAX_STR_SIZE]; + uint32_to_str(key_str, save.key); ESP_LOGVV(TAG, "Checking if NVS data %s has changed", key_str); if (this->is_changed_(this->nvs_handle, save, key_str)) { esp_err_t err = nvs_set_blob(this->nvs_handle, key_str, save.data.data(), save.data.size()); diff --git a/esphome/components/libretiny/preferences.cpp b/esphome/components/libretiny/preferences.cpp index fba6717294..313b36d31e 100644 --- a/esphome/components/libretiny/preferences.cpp +++ b/esphome/components/libretiny/preferences.cpp @@ -3,7 +3,6 @@ #include "preferences.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include #include #include @@ -11,9 +10,6 @@ namespace esphome::libretiny { static const char *const TAG = "preferences"; -// Buffer size for converting uint32_t to string: max "4294967295" (10 chars) + null terminator + 1 padding -static constexpr size_t KEY_BUFFER_SIZE = 12; - struct NVSData { uint32_t key; SmallInlineBuffer<8> data; // Most prefs fit in 8 bytes (covers fan, cover, select, etc.) @@ -50,8 +46,8 @@ bool LibreTinyPreferenceBackend::load(uint8_t *data, size_t len) { } } - char key_str[KEY_BUFFER_SIZE]; - snprintf(key_str, sizeof(key_str), "%" PRIu32, this->key); + char key_str[UINT32_MAX_STR_SIZE]; + uint32_to_str(key_str, this->key); fdb_blob_make(this->blob, data, len); size_t actual_len = fdb_kv_get_blob(this->db, key_str, this->blob); if (actual_len != len) { @@ -92,8 +88,8 @@ bool LibreTinyPreferences::sync() { uint32_t last_key = 0; for (const auto &save : s_pending_save) { - char key_str[KEY_BUFFER_SIZE]; - snprintf(key_str, sizeof(key_str), "%" PRIu32, save.key); + char key_str[UINT32_MAX_STR_SIZE]; + uint32_to_str(key_str, save.key); ESP_LOGVV(TAG, "Checking if FDB data %s has changed", key_str); if (this->is_changed_(&this->db, save, key_str)) { ESP_LOGV(TAG, "sync: key: %s, len: %zu", key_str, save.data.size()); diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index cbe22dd09a..34ecaf137f 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -380,6 +380,20 @@ static char *format_hex_internal(char *buffer, size_t buffer_size, const uint8_t return buffer; } +char *uint32_to_str_unchecked(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; +} + char *format_hex_to(char *buffer, size_t buffer_size, const uint8_t *data, size_t length) { return format_hex_internal(buffer, buffer_size, data, length, 0, 'a'); } diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 3c42d7df07..54bc32a5a5 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; } +/// 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. +char *uint32_to_str_unchecked(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). +inline size_t uint32_to_str(std::span buf, uint32_t val) { + char *end = uint32_to_str_unchecked(buf.data(), val); + *end = '\0'; + return static_cast(end - buf.data()); +} + /// 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/benchmarks/core/bench_helpers.cpp b/tests/benchmarks/core/bench_helpers.cpp index d9a9d158a3..1ce9101ff6 100644 --- a/tests/benchmarks/core/bench_helpers.cpp +++ b/tests/benchmarks/core/bench_helpers.cpp @@ -1,4 +1,6 @@ #include +#include +#include #include "esphome/core/helpers.h" @@ -307,4 +309,58 @@ static void Base64Decode_32Bytes(benchmark::State &state) { } BENCHMARK(Base64Decode_32Bytes); +// --- uint32_to_str() vs snprintf --- + +static void Uint32ToStr_Small(benchmark::State &state) { + char buf[UINT32_MAX_STR_SIZE]; + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + uint32_to_str(buf, 12345); + benchmark::DoNotOptimize(buf); + benchmark::ClobberMemory(); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Uint32ToStr_Small); + +static void Snprintf_Uint32_Small(benchmark::State &state) { + char buf[UINT32_MAX_STR_SIZE]; + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + snprintf(buf, sizeof(buf), "%" PRIu32, static_cast(12345)); + benchmark::DoNotOptimize(buf); + benchmark::ClobberMemory(); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Snprintf_Uint32_Small); + +static void Uint32ToStr_Large(benchmark::State &state) { + char buf[UINT32_MAX_STR_SIZE]; + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + uint32_to_str(buf, 4294967295u); + benchmark::DoNotOptimize(buf); + benchmark::ClobberMemory(); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Uint32ToStr_Large); + +static void Snprintf_Uint32_Large(benchmark::State &state) { + char buf[UINT32_MAX_STR_SIZE]; + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + snprintf(buf, sizeof(buf), "%" PRIu32, static_cast(4294967295u)); + benchmark::DoNotOptimize(buf); + benchmark::ClobberMemory(); + } + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Snprintf_Uint32_Large); + } // namespace esphome::benchmarks diff --git a/tests/components/core/test_uint32_to_str.cpp b/tests/components/core/test_uint32_to_str.cpp new file mode 100644 index 0000000000..fc754429ec --- /dev/null +++ b/tests/components/core/test_uint32_to_str.cpp @@ -0,0 +1,77 @@ +#include + +#include "esphome/core/helpers.h" + +namespace esphome::core::testing { + +// --- uint32_to_str_unchecked() (internal, raw pointer) --- + +TEST(Uint32ToStr, InternalZero) { + char buf[UINT32_MAX_STR_SIZE]; + char *end = uint32_to_str_unchecked(buf, 0); + *end = '\0'; + EXPECT_STREQ(buf, "0"); + EXPECT_EQ(end - buf, 1); +} + +TEST(Uint32ToStr, InternalSingleDigit) { + char buf[UINT32_MAX_STR_SIZE]; + char *end = uint32_to_str_unchecked(buf, 7); + *end = '\0'; + EXPECT_STREQ(buf, "7"); +} + +TEST(Uint32ToStr, InternalMultiDigit) { + char buf[UINT32_MAX_STR_SIZE]; + char *end = uint32_to_str_unchecked(buf, 12345); + *end = '\0'; + EXPECT_STREQ(buf, "12345"); + EXPECT_EQ(end - buf, 5); +} + +TEST(Uint32ToStr, InternalMaxValue) { + char buf[UINT32_MAX_STR_SIZE]; + char *end = uint32_to_str_unchecked(buf, 4294967295u); + *end = '\0'; + EXPECT_STREQ(buf, "4294967295"); + EXPECT_EQ(end - buf, 10); +} + +TEST(Uint32ToStr, InternalPowersOfTen) { + char buf[UINT32_MAX_STR_SIZE]; + char *end; + + end = uint32_to_str_unchecked(buf, 10); + *end = '\0'; + EXPECT_STREQ(buf, "10"); + + end = uint32_to_str_unchecked(buf, 100); + *end = '\0'; + EXPECT_STREQ(buf, "100"); + + end = uint32_to_str_unchecked(buf, 1000000); + *end = '\0'; + EXPECT_STREQ(buf, "1000000"); +} + +// --- uint32_to_str() (public, span API) --- + +TEST(Uint32ToStr, SpanZero) { + char buf[UINT32_MAX_STR_SIZE]; + EXPECT_EQ(uint32_to_str(buf, 0), 1u); + EXPECT_STREQ(buf, "0"); +} + +TEST(Uint32ToStr, SpanMultiDigit) { + char buf[UINT32_MAX_STR_SIZE]; + EXPECT_EQ(uint32_to_str(buf, 12345), 5u); + EXPECT_STREQ(buf, "12345"); +} + +TEST(Uint32ToStr, SpanMaxValue) { + char buf[UINT32_MAX_STR_SIZE]; + EXPECT_EQ(uint32_to_str(buf, 4294967295u), 10u); + EXPECT_STREQ(buf, "4294967295"); +} + +} // namespace esphome::core::testing From da9fbb8044c1766ceda1b48e68bcdfafc89c9ccc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 07:50:11 -1000 Subject: [PATCH 069/575] [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 4729efbd0478aa8ecaa0840b382f7703b8a933d1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 07:50:28 -1000 Subject: [PATCH 070/575] [light] Deduplicate color_uncorrect channel math via shared helper (#15727) --- .../components/light/esp_color_correction.cpp | 16 +++++++++ .../components/light/esp_color_correction.h | 33 +++++-------------- 2 files changed, 24 insertions(+), 25 deletions(-) diff --git a/esphome/components/light/esp_color_correction.cpp b/esphome/components/light/esp_color_correction.cpp index 9d731a2bd5..e793226bb1 100644 --- a/esphome/components/light/esp_color_correction.cpp +++ b/esphome/components/light/esp_color_correction.cpp @@ -22,4 +22,20 @@ 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; + // 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 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 9f5ed938e5722aab1a69affe4ce58dc11e3fbc68 Mon Sep 17 00:00:00 2001 From: Alexey Spirkov Date: Tue, 14 Apr 2026 21:07:16 +0300 Subject: [PATCH 071/575] [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 79cee864cbc0934af3f4e4c895c11b024660a72f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 08:20:14 -1000 Subject: [PATCH 072/575] [esphome][ota] Disable loop while idle, wake on listening-socket activity (#15636) --- esphome/components/esphome/ota/__init__.py | 10 ++ .../components/esphome/ota/ota_esphome.cpp | 39 ++++++- .../components/socket/lwip_raw_tcp_impl.cpp | 8 ++ esphome/core/lwip_fast_select.c | 18 +++ esphome/core/lwip_fast_select.h | 6 + tests/component_tests/ota/test_esphome_ota.py | 105 ++++++++++++++++++ 6 files changed, 180 insertions(+), 6 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 337064dd27..5d35910fbd 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -78,6 +78,14 @@ def ota_esphome_final_validate(config): else: new_ota_conf.append(ota_conf) + if len(merged_ota_esphome_configs_by_port) > 1: + raise cv.Invalid( + 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()) full_conf[CONF_OTA] = new_ota_conf @@ -147,6 +155,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]) + # 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) 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..47f661a8ea 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 @@ -28,6 +31,17 @@ 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 +// 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; + +// 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(); + } +} + void ESPHomeOTAComponent::setup() { this->server_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0).release(); // monitored for incoming connections if (this->server_ == nullptr) { @@ -65,6 +79,14 @@ void ESPHomeOTAComponent::setup() { this->server_failed_(LOG_STR("listen")); return; } + + // loop() self-disables on its first idle tick; no explicit disable_loop() needed here. + 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. + esphome_fast_select_set_ota_listener_sock(esphome_lwip_get_sock(this->server_->get_fd())); +#endif } void ESPHomeOTAComponent::dump_config() { @@ -81,13 +103,15 @@ 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_(); + // 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; } + this->handle_handshake_(); } static const uint8_t FEATURE_SUPPORTS_COMPRESSION = 0x01; @@ -566,6 +590,9 @@ void ESPHomeOTAComponent::cleanup_connection_() { #ifdef USE_OTA_PASSWORD this->cleanup_auth_(); #endif + // 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/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 86131d3ddb..c6692b0165 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -11,6 +11,10 @@ #include "esphome/core/wake.h" #include "esphome/core/log.h" +#ifdef USE_OTA_PLATFORM_ESPHOME +extern "C" void esphome_wake_ota_component_any_context(); +#endif + #ifdef USE_ESP8266 #include // For esp_schedule() #elif defined(USE_RP2040) @@ -854,6 +858,10 @@ 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_PLATFORM_ESPHOME + // 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. esphome::wake_loop_any_context(); return ERR_OK; diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index bb3acbafcb..36000d4e77 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -157,6 +157,17 @@ _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_PLATFORM_ESPHOME +static struct netconn *s_ota_listener_conn = NULL; +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; +} +#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. // 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) { @@ -171,6 +182,13 @@ 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_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) { + 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 20ac191673..3b5e449148 100644 --- a/esphome/core/lwip_fast_select.h +++ b/esphome/core/lwip_fast_select.h @@ -53,6 +53,12 @@ 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); +/// 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. /// Must be called with the TCPIP core lock held (LwIPLock in C++). /// This bypasses lwip_setsockopt() overhead (socket lookups, switch cascade, 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 3f82a3a519545f93f82e55bae1745f3c527b543b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 08:20:31 -1000 Subject: [PATCH 073/575] [core] Inline Millis64Impl::compute() on single-threaded platforms (#15684) 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/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 21af94ea4e..7634b3bd08 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..b8a299ff7e 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 preprocessor 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 506edaadd5d4355a175e8cc46e76d0f280f2e24d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 09:08:30 -1000 Subject: [PATCH 074/575] [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 e48c7165c59baea515a4a6f592710203971d16ff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Apr 2026 09:45:42 -1000 Subject: [PATCH 075/575] [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 2db2b89eb1648c95034024c22f2dc64d70681b8f 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 076/575] [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 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 077/575] 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 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 078/575] [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 079/575] [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 080/575] 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 081/575] 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 01b5bef37fafaf4a4a1bb4db1240c5ea43dac717 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Apr 2026 17:44:42 -1000 Subject: [PATCH 082/575] [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 083/575] [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 084/575] [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 085/575] [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 086/575] [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 087/575] [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 088/575] [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 089/575] [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 090/575] [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 091/575] [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 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 092/575] [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 093/575] [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 094/575] [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 095/575] [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 096/575] [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 097/575] [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 098/575] [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 099/575] 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 100/575] [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 cfe8c0eeee0fa61fa40c1d47a89203b4bdee0f1a Mon Sep 17 00:00:00 2001 From: Diorcet Yann Date: Fri, 17 Apr 2026 12:20:55 +0200 Subject: [PATCH 101/575] [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 102/575] [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 103/575] [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 104/575] [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 105/575] [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 106/575] [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 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 107/575] [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 108/575] [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 109/575] 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 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 110/575] [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 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 111/575] [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 112/575] [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 6ebe1e92eb64cf099eb4f1ae97dba5fc1471905a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Apr 2026 17:54:12 -0500 Subject: [PATCH 113/575] [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 114/575] [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 115/575] [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 df72aa26c0a11a00b581ef65f5c528c840a20f70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Apr 2026 06:58:54 -0500 Subject: [PATCH 116/575] [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 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 117/575] 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 118/575] [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 b293be23b050a1da4efc5a2652b70c6168477b5d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Apr 2026 08:11:38 -0500 Subject: [PATCH 119/575] [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 38d894dfe7f012fca0545a8fe1356b4b1f4b0325 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Apr 2026 08:17:22 -0500 Subject: [PATCH 120/575] [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 7a23a339e9a6f8234f2e8ad3339f047e3b18e27f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Apr 2026 16:00:31 -0500 Subject: [PATCH 121/575] [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 122/575] [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 123/575] [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 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 124/575] [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 f0c21520aa5644370d1ed56e05702c35a95be721 Mon Sep 17 00:00:00 2001 From: guillempages Date: Mon, 20 Apr 2026 13:56:56 +0200 Subject: [PATCH 125/575] [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 126/575] [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 127/575] [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 128/575] [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 129/575] [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 130/575] [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 131/575] [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 132/575] [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 133/575] [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 134/575] [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 135/575] [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 136/575] [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 137/575] [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 138/575] [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 139/575] [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 140/575] [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 141/575] [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 142/575] [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 143/575] 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 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 144/575] [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 145/575] [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 146/575] [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 147/575] [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 148/575] [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 149/575] [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 150/575] [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 151/575] [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 152/575] [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 153/575] [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 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 154/575] [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 43c6b839cd70e3c2430964ec441c3815ac203d91 Mon Sep 17 00:00:00 2001 From: Geoff Date: Tue, 21 Apr 2026 07:00:03 -0700 Subject: [PATCH 155/575] [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 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 156/575] 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 157/575] [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 158/575] [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 159/575] [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 160/575] 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 161/575] 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 162/575] 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 edcf96d0575232c153e8d4d7bb6de2aab0410c9a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Apr 2026 06:24:09 +0200 Subject: [PATCH 163/575] [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 164/575] [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 165/575] [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 166/575] [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 167/575] [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 168/575] [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 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 169/575] 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 170/575] 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 171/575] [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 172/575] [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 173/575] [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 174/575] [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 175/575] [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 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 176/575] [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 177/575] [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 178/575] [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 179/575] [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 180/575] [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 181/575] [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 182/575] [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 183/575] [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 184/575] [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: Wed, 22 Apr 2026 17:57:15 -0500 Subject: [PATCH 185/575] [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 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 186/575] 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 187/575] 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 188/575] [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 189/575] [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 190/575] 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 191/575] [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 192/575] [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 193/575] [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 194/575] [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 195/575] 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 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 196/575] [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 50c181671cc886457fd8c62dc376d97a087874aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 06:47:16 -0500 Subject: [PATCH 197/575] [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 198/575] [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 199/575] [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 200/575] [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 201/575] [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 202/575] [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 203/575] [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 204/575] [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 d759f1a56751207689f5db024181739c96d94646 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 23 Apr 2026 16:53:52 -0400 Subject: [PATCH 205/575] [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 206/575] [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 207/575] [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 b4a86e46b256020be129a67c59b48ccf3e5c3311 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 23 Apr 2026 21:22:47 -0400 Subject: [PATCH 208/575] [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 3ccaa771a7423f95cc1bd4cb1b5a77d5b7f04324 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 23 Apr 2026 21:46:25 -0400 Subject: [PATCH 209/575] [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 404620b99cc805225c328ce49d81a0fe4e07dff1 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Fri, 24 Apr 2026 04:31:46 +0200 Subject: [PATCH 210/575] [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 eceb534895dcaa1c9c77c906500799fefeb4f6de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 02:19:59 -0500 Subject: [PATCH 211/575] [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 ae02ab38656f484e55468302015016a9a59440a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 03:42:36 -0500 Subject: [PATCH 212/575] [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 213/575] [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 214/575] [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 773b4d887bf25d8b564aab10f1c3ddd27ee65676 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Apr 2026 08:11:29 -0500 Subject: [PATCH 215/575] [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 216/575] [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 f132b7dc07f2f402eab87a8ee445a64c5b403e22 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 24 Apr 2026 10:09:03 -0400 Subject: [PATCH 217/575] [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 218/575] [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 219/575] [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 9caf9ee02336fb7754ead08a11fd2da10c77d74a Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Fri, 24 Apr 2026 12:53:03 -0400 Subject: [PATCH 220/575] [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 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 221/575] 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 222/575] 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 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 223/575] 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 224/575] 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 b5ccd55f4ed8cf9c756505a0b2c1767f5d957be5 Mon Sep 17 00:00:00 2001 From: Javier Peletier Date: Sat, 25 Apr 2026 19:06:58 +0200 Subject: [PATCH 225/575] [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 4f8feb86f0fdea15ff09dbfee20b10ee899ae2e0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Apr 2026 15:43:05 -0500 Subject: [PATCH 226/575] [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 227/575] 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 228/575] [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 bc33260c61f2f635f7240ea7e5e1380fee504e95 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Sat, 25 Apr 2026 22:33:02 -0500 Subject: [PATCH 229/575] [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 230/575] [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 231/575] [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 dc57969afdc96dd546ceaca27474200f090e6c6e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 03:39:24 -0500 Subject: [PATCH 232/575] [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 68625a1b76aafaa5a859cea3c090333dfa81ce40 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Sun, 26 Apr 2026 13:11:09 +0400 Subject: [PATCH 233/575] [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 b084fa449008b4dbf4162d2e702d280cb25a9433 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Sun, 26 Apr 2026 15:31:32 +0400 Subject: [PATCH 234/575] [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 c8d4420408c33680ab47c4576129fc4ad3dad1d7 Mon Sep 17 00:00:00 2001 From: Boris Krivonog Date: Sun, 26 Apr 2026 14:19:49 +0200 Subject: [PATCH 235/575] [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 236/575] [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 237/575] [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 238/575] =?UTF-8?q?[bluetooth=5Fproxy]=20Partial=20revert?= =?UTF-8?q?=20of=20loop()=20=E2=86=92=20set=5Finterval=20migration=20(#159?= =?UTF-8?q?92)?= 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 239/575] [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 240/575] [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 241/575] [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 242/575] [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 243/575] [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 244/575] [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 2e096bb036abfefa0e86d3830a55b9e5aeecd270 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Apr 2026 21:54:15 -0500 Subject: [PATCH 245/575] [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 112646a9c4ba92f49c3589e664d064f56193dee3 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 27 Apr 2026 05:02:09 +0200 Subject: [PATCH 246/575] [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 247/575] [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 dec5d0449bd1c844ba89f9c5900d48db8b17a466 Mon Sep 17 00:00:00 2001 From: plazarre Date: Mon, 27 Apr 2026 06:51:54 -0500 Subject: [PATCH 248/575] [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 249/575] [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 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 250/575] [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 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 251/575] [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 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 252/575] [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 253/575] [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 a03de7cea2fa0747a59fe7502f4d072fa68cf25e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Apr 2026 20:23:08 -0500 Subject: [PATCH 254/575] [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 255/575] [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 256/575] [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 257/575] [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 258/575] [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 259/575] [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 260/575] [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 261/575] [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 0759a3c6815e88fd333fef2ed843027ce24d445a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Apr 2026 08:48:13 -0500 Subject: [PATCH 262/575] [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 263/575] [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 264/575] [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 265/575] [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 266/575] [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 267/575] [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 268/575] [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 269/575] [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 270/575] 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 271/575] [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 272/575] 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 273/575] [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 274/575] [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 275/575] [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 276/575] [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 277/575] [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 278/575] [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 279/575] [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 280/575] [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 281/575] [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 282/575] [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 283/575] [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 284/575] [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 285/575] [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 286/575] [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 287/575] [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 288/575] [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 289/575] [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 290/575] [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 291/575] [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 292/575] [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 293/575] [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 294/575] [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 295/575] [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 296/575] [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 297/575] [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 298/575] [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 299/575] [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 300/575] [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 301/575] [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 302/575] [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 303/575] [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 304/575] [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 305/575] [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 306/575] [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 307/575] [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 308/575] [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 309/575] [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 310/575] [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 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 311/575] [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 312/575] [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 313/575] [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 314/575] [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 315/575] [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 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 316/575] [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 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 317/575] [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 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 318/575] [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 319/575] [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 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 320/575] [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 69a33d8ac0c879646923767b7cc2164b69c838c4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:31:55 -0500 Subject: [PATCH 321/575] [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 7fba57ce51e5babfb77eff76603b5008236c4f55 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 12:45:30 -0500 Subject: [PATCH 322/575] [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 323/575] [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 44cabc191d65b7fe18a4dd95714e252a5a3a5597 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 13:06:41 -0500 Subject: [PATCH 324/575] [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 325/575] [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 326/575] [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 327/575] [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 c41f38e16d2a3f179f02cfb56a99fbbd7ca76217 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 13:24:37 -0500 Subject: [PATCH 328/575] [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 329/575] [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 330/575] [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 331/575] [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 813964714ce881b85b0730347e739591124cf89a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 15:09:08 -0500 Subject: [PATCH 332/575] [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 333/575] [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 334/575] [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 335/575] [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 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 336/575] [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 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 337/575] [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 9b1f5c59bb6ea7417174a8751d458c4f9bdb2722 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 18:05:38 -0500 Subject: [PATCH 338/575] [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 339/575] [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 340/575] 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 341/575] [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 342/575] [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 f0bffed3c04c75f65513a4405914e093340d5782 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2026 22:42:17 -0500 Subject: [PATCH 343/575] [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 344/575] [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 345/575] 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 346/575] [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 a8b0133ec115984ed05b8129a6e85dbc56e93166 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 30 Apr 2026 08:49:28 -0400 Subject: [PATCH 347/575] [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 2758aa551782464ed34d1a1f7affe91e7d9120e8 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 30 Apr 2026 09:12:39 -0400 Subject: [PATCH 348/575] [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 f1d3be4bdaaa1d6fcf7119d075b41cdc57ea31eb Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 30 Apr 2026 12:03:40 -0400 Subject: [PATCH 349/575] [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 d48aad8c4d42431beb4f7f89f35bf42628128a04 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2026 12:27:54 -0500 Subject: [PATCH 350/575] [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 351/575] [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 19:10:53 -0500 Subject: [PATCH 352/575] [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 353/575] [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 354/575] [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 355/575] [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 356/575] [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 357/575] [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 358/575] [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 359/575] [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 360/575] [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 361/575] [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 362/575] [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 363/575] [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 364/575] [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 faa61696e036cb26e44ef0e4a28400e0636c5ca3 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 30 Apr 2026 21:43:24 -0400 Subject: [PATCH 365/575] [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 366/575] [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 367/575] [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 368/575] [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 369/575] [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 370/575] [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 f073c1cabe180f10fc9af0b345a2b03cc4ec164e Mon Sep 17 00:00:00 2001 From: Oliver Kleinecke Date: Fri, 1 May 2026 12:43:13 +0200 Subject: [PATCH 371/575] [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 372/575] [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 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 373/575] [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 81d147ff9e55bb164821d516bb8ffbabca7fd7a1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 May 2026 14:31:56 -0500 Subject: [PATCH 374/575] [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 375/575] 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 376/575] [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 0f174ee62620818c90cb73bab9e9de5f98e0f483 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 May 2026 17:55:40 -0500 Subject: [PATCH 377/575] [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 85e1e4b95ec6ec3d9c7ba3a5819d86d1ae1fb8a6 Mon Sep 17 00:00:00 2001 From: tomaszduda23 Date: Mon, 4 May 2026 02:37:32 +0200 Subject: [PATCH 378/575] [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 379/575] [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 380/575] [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 381/575] [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 382/575] [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 383/575] [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 384/575] [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 385/575] [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 386/575] [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 387/575] [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 388/575] [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 389/575] [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 390/575] [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 391/575] [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 392/575] [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 393/575] [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 394/575] [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 395/575] [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 396/575] [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 397/575] [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 857e529803d10971ffd36089068b6590fac3dc6f Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Mon, 4 May 2026 18:41:50 -0400 Subject: [PATCH 398/575] [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 399/575] [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 400/575] 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 401/575] [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 402/575] [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 403/575] [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 404/575] [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 405/575] [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 406/575] [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 407/575] [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 408/575] [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 409/575] [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 410/575] [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 411/575] [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 412/575] [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 413/575] [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 414/575] [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 415/575] [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 416/575] [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 417/575] [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 418/575] [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 419/575] [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 420/575] [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 421/575] [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 422/575] [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 423/575] [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 424/575] [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 425/575] [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 426/575] [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 427/575] [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 428/575] [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 429/575] [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 430/575] [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 431/575] [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 432/575] [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 433/575] [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 434/575] [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 435/575] [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 436/575] [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 437/575] [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 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 438/575] 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 439/575] [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 440/575] [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 441/575] [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 442/575] [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 443/575] [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 444/575] 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 445/575] [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 446/575] [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 447/575] [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 448/575] [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 449/575] [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 450/575] [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 451/575] [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 452/575] [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 453/575] [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 454/575] [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 455/575] [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 456/575] [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 457/575] [i2s_audio] Refactor SPDIF output, fixing synchronization problems (#16319) --- .../i2s_audio/speaker/i2s_audio_spdif.cpp | 436 +++++++----------- .../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, 206 insertions(+), 311 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; - } - - // 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 + 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); + + 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 458/575] [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 459/575] [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 460/575] [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 461/575] [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 462/575] [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 463/575] [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 464/575] [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 465/575] [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 466/575] [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 467/575] [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 468/575] [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 469/575] [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 470/575] [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 471/575] [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 472/575] [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 473/575] [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 474/575] [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 475/575] [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 476/575] [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 477/575] [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 478/575] [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 479/575] [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 480/575] [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 481/575] [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 482/575] [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 483/575] [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 484/575] [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 485/575] [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 486/575] [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 487/575] 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 488/575] 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 489/575] [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 490/575] [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 491/575] [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 492/575] [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 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 493/575] [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 494/575] [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 495/575] 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 496/575] [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 f54480ec48abaec9218caef3a6b8b8a4086f0e8a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 May 2026 15:20:25 -0500 Subject: [PATCH 497/575] [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 498/575] [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 66e4a1dfa831ca497028f3cc2a7bddcc6865c740 Mon Sep 17 00:00:00 2001 From: Boris Krivonog Date: Tue, 12 May 2026 22:39:21 +0200 Subject: [PATCH 499/575] [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 500/575] [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 501/575] [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 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 502/575] 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 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 503/575] [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 504/575] 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 505/575] [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 506/575] [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 507/575] [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 508/575] [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 509/575] [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 510/575] [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 511/575] [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 512/575] [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 513/575] [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 514/575] [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 515/575] [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 516/575] [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 517/575] [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 518/575] [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 45a4811bb424f399515ccb08a49ef5d9f81fb634 Mon Sep 17 00:00:00 2001 From: Boris Krivonog Date: Wed, 13 May 2026 08:40:19 +0200 Subject: [PATCH 519/575] [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 520/575] [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 521/575] [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 522/575] [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 523/575] [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 524/575] [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 525/575] [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 526/575] [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 527/575] [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 528/575] [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 529/575] [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 530/575] 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 531/575] [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 532/575] 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 533/575] [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 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 534/575] 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 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 535/575] [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 536/575] [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 537/575] [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 538/575] [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 539/575] [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 540/575] [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 541/575] [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 542/575] [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 543/575] [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 544/575] [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 545/575] [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 546/575] [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 547/575] [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 548/575] [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 549/575] [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 550/575] 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 551/575] [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 552/575] [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 553/575] 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 554/575] [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 555/575] 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 556/575] 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 557/575] [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 558/575] 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 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 559/575] [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 560/575] 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 561/575] [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 562/575] [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 563/575] [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 564/575] [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 565/575] [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 566/575] [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 567/575] [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 568/575] [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 569/575] 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 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 570/575] 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 571/575] 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 572/575] [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 573/575] [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 574/575] 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 575/575] 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 = (