From 8749c3d18d29ba1c63b7790e0f3f7a783d7c9c84 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Aug 2026 12:40:57 -0500 Subject: [PATCH 01/30] [esp32_ble_tracker] Warn when the scan window is above 600ms with wifi (#18725) --- .../components/esp32_ble_tracker/__init__.py | 47 +++++++++++++++++ .../test_scan_window_default.py | 52 +++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index c6e34f37ca..906144e5fd 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -143,6 +143,13 @@ def validate_max_connections_deprecated(config: ConfigType) -> ConfigType: # BLE uses the airtime wifi does not claim. IDF_SCAN_WINDOW_FIX_VERSION = cv.Version(5, 5, 5) +# Above this the scanner holds the shared radio long enough that wifi drops +# packets and connections on some access points (others cope fine, which is +# why this is a warning and not an error); old proxy configs with 1100 ms +# windows are a recurring cause of instability (esphome/esphome#18655). Only +# wifi shares the radio; long windows are fine on ethernet builds. +MAX_RECOMMENDED_WIFI_SCAN_WINDOW = TimePeriod(milliseconds=600) + @dataclass class TrackerData: @@ -209,6 +216,45 @@ def _raise_defaulted_scan_window(config: ConfigType) -> ConfigType: return config +def _warn_long_scan_window_with_wifi(config: ConfigType) -> ConfigType: + """Warn when the scan window is long enough to starve wifi. + + Runs after _raise_defaulted_scan_window so it sees the final window. + software_coexistence is only present when wifi is configured, so ethernet + builds never warn: BLE has the radio to itself there. Presence is what + matters, not the value; with the arbiter disabled a long window starves + wifi outright. + """ + params = config[CONF_SCAN_PARAMETERS] + window = params[CONF_WINDOW] + if CONF_SOFTWARE_COEXISTENCE not in config: + return config + if window <= MAX_RECOMMENDED_WIFI_SCAN_WINDOW: + return config + if _get_data().scan_window_defaulted: + # The window was raised to match the interval, so point at the key the + # user actually set. + _LOGGER.warning( + "BLE scan interval of %s sets the scan window to the same value, " + "which starves wifi on the same radio and can cause wifi disconnects " + "depending on the access point; keep the interval at or below %s " + "(for example interval: 320ms). Long windows are only a problem with " + "wifi, they are fine on ethernet", + params[CONF_INTERVAL], + MAX_RECOMMENDED_WIFI_SCAN_WINDOW, + ) + return config + _LOGGER.warning( + "BLE scan window of %s with wifi on the same radio starves wifi and " + "can cause wifi disconnects depending on the access point; keep the " + "window at or below %s (for example interval: 320ms, window: 300ms). " + "Long windows are only a problem with wifi, they are fine on ethernet", + window, + MAX_RECOMMENDED_WIFI_SCAN_WINDOW, + ) + return config + + # 320 ms is the ESP-IDF reference scan interval; the shared schema also # tightens validation to the controller's 2.5 ms .. 10240 ms range and rejects # window/interval pairs that collapse to the same 0.625 ms unit count. @@ -271,6 +317,7 @@ CONFIG_SCHEMA = cv.All( ).extend(cv.COMPONENT_SCHEMA), validate_max_connections_deprecated, _raise_defaulted_scan_window, + _warn_long_scan_window_with_wifi, ) diff --git a/tests/component_tests/esp32_ble_tracker/test_scan_window_default.py b/tests/component_tests/esp32_ble_tracker/test_scan_window_default.py index 8612ac6732..1381aaf4c2 100644 --- a/tests/component_tests/esp32_ble_tracker/test_scan_window_default.py +++ b/tests/component_tests/esp32_ble_tracker/test_scan_window_default.py @@ -12,6 +12,7 @@ arbiter a full-duty scan would starve wifi, so the 30 ms default is kept. from __future__ import annotations from collections.abc import Callable +import logging from pathlib import Path import pytest @@ -221,3 +222,54 @@ def test_connection_scan_window_codegen( assert window_call in main_cpp assert ("set_connection_scan_window(48)" in main_cpp) == connection_call assert ("'connection_scan_window' has no effect" in caplog.text) == warns + + +@pytest.mark.parametrize( + ("wifi", "params", "expect_warning"), + [ + (True, {"interval": "1100ms", "window": "1100ms"}, True), + (True, {"interval": "1100ms", "window": "601ms"}, True), + (True, {"interval": "1100ms", "window": "600ms"}, False), + (False, {"interval": "1100ms", "window": "1100ms"}, False), + ], +) +def test_long_window_with_wifi_warns( + stage_esp32: Callable[..., None], + caplog: pytest.LogCaptureFixture, + wifi: bool, + params: ConfigType, + expect_warning: bool, +) -> None: + """A scan window above 600 ms warns only when wifi shares the radio.""" + stage_esp32("5.5.5", wifi=wifi) + with caplog.at_level(logging.WARNING): + _scan_params({"scan_parameters": params}) + assert ("starves wifi" in caplog.text) is expect_warning + + +def test_long_window_warns_with_coexistence_disabled( + stage_esp32: Callable[..., None], + caplog: pytest.LogCaptureFixture, +) -> None: + """Disabling the arbiter is the worst case for a long window, so it still warns.""" + stage_esp32("5.5.5", wifi=True) + with caplog.at_level(logging.WARNING): + _scan_params( + { + CONF_SOFTWARE_COEXISTENCE: False, + "scan_parameters": {"interval": "1100ms", "window": "1100ms"}, + } + ) + assert "BLE scan window of 1100ms" in caplog.text + + +def test_raised_window_warning_points_at_interval( + stage_esp32: Callable[..., None], + caplog: pytest.LogCaptureFixture, +) -> None: + """When the window was raised to a long interval, the warning names the interval.""" + stage_esp32("5.5.5", wifi=True) + with caplog.at_level(logging.WARNING): + _scan_params({"scan_parameters": {"interval": "1s"}}) + assert "BLE scan interval of 1s" in caplog.text + assert "BLE scan window of" not in caplog.text From 145373ed134c1a40c8d9e9f53d6d3cff44cda99c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Aug 2026 13:04:41 -0500 Subject: [PATCH 02/30] [api] Widen message type storage to uint16_t (#18526) --- esphome/components/api/api_connection.cpp | 16 +- esphome/components/api/api_connection.h | 37 +-- esphome/components/api/api_frame_helper.h | 18 +- .../components/api/api_frame_helper_noise.cpp | 4 +- .../components/api/api_frame_helper_noise.h | 4 +- .../api/api_frame_helper_plaintext.cpp | 24 +- .../api/api_frame_helper_plaintext.h | 5 +- esphome/components/api/api_pb2.h | 284 +++++++++--------- esphome/components/api/proto.h | 5 - script/api_protobuf/api_protobuf.py | 29 +- .../api/test_api_protobuf_generator.py | 18 +- 11 files changed, 238 insertions(+), 206 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 05abbf0b75..9b1026d2a9 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1790,10 +1790,12 @@ void APIConnection::complete_authentication_() { bool APIConnection::send_hello_response_(const HelloRequest &msg) { // Copy client name with truncation if needed (set_client_name handles truncation) this->helper_->set_client_name(msg.client_info.c_str(), msg.client_info.size()); - this->client_api_version_major_ = msg.api_version_major; - this->client_api_version_minor_ = msg.api_version_minor; + this->client_api_version_major_ = + static_cast(std::min(msg.api_version_major, std::numeric_limits::max())); + this->client_api_version_minor_ = + static_cast(std::min(msg.api_version_minor, std::numeric_limits::max())); char peername[socket::SOCKADDR_STR_LEN]; - ESP_LOGV(TAG, "Hello from client: '%s' | %s | API Version %" PRIu16 ".%" PRIu16, this->helper_->get_client_name(), + ESP_LOGV(TAG, "Hello from client: '%s' | %s | API Version %u.%u", this->helper_->get_client_name(), this->helper_->get_peername_to(peername), this->client_api_version_major_, this->client_api_version_minor_); HelloResponse resp; @@ -2224,7 +2226,7 @@ bool APIConnection::try_to_clear_buffer_slow_(bool log_out_of_space) { } return false; } -bool APIConnection::send_message_(uint32_t payload_size, uint8_t message_type, MessageEncodeFn encode_fn, +bool APIConnection::send_message_(uint32_t payload_size, uint16_t message_type, MessageEncodeFn encode_fn, const void *msg) { #ifdef HAS_PROTO_MESSAGE_DUMP // Skip dump for log messages (recursive logging risk) and camera frames (high-frequency noise) @@ -2253,7 +2255,7 @@ uint16_t APIConnection::encode_to_buffer_slow(uint32_t calculated_size, MessageE APIConnection *conn, uint32_t remaining_size) { return encode_to_buffer(calculated_size, encode_fn, msg, conn, remaining_size); } -bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) { +bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint16_t message_type) { const bool is_log_message = (message_type == SubscribeLogsResponse::MESSAGE_TYPE); if (!this->try_to_clear_buffer(!is_log_message)) { @@ -2283,12 +2285,12 @@ void APIConnection::on_fatal_error() { this->flags_.remove = true; } -bool APIConnection::schedule_message_front_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size) { +bool APIConnection::schedule_message_front_(EntityBase *entity, uint16_t message_type, uint8_t estimated_size) { this->deferred_batch_.add_item_front(entity, message_type, estimated_size); return this->schedule_batch_(); } -bool APIConnection::send_message_smart_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size, +bool APIConnection::send_message_smart_(EntityBase *entity, uint16_t message_type, uint8_t estimated_size, uint8_t aux_data_index) { if (this->should_send_immediately_(message_type) && this->helper_->can_write_without_blocking()) { auto &shared_buf = this->parent_->get_shared_buffer_ref(); diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 1b47c23cfe..5a554f4857 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -326,8 +326,10 @@ class APIConnection final : public APIServerConnectionBase { bool is_marked_for_removal() const { return this->flags_.remove; } uint8_t get_log_subscription_level() const { return this->flags_.log_subscription; } - // Get client API version for feature detection - bool client_supports_api_version(uint16_t major, uint16_t minor) const { + // Get client API version for feature detection. + // Stored versions saturate at 255 (see send_hello_response_), so requesting + // a minimum above that can never match. + bool client_supports_api_version(uint8_t major, uint8_t minor) const { return this->client_api_version_major_ > major || (this->client_api_version_major_ == major && this->client_api_version_minor_ >= minor); } @@ -374,7 +376,7 @@ class APIConnection final : public APIServerConnectionBase { return true; return this->try_to_clear_buffer_slow_(log_out_of_space); } - bool send_buffer(ProtoWriteBuffer buffer, uint8_t message_type); + bool send_buffer(ProtoWriteBuffer buffer, uint16_t message_type); const char *get_name() const { return this->helper_->get_client_name(); } /// Get peer name (IP address) into caller-provided buffer, returns buf for convenience @@ -423,7 +425,7 @@ class APIConnection final : public APIServerConnectionBase { } // Non-template buffer management for send_message - bool send_message_(uint32_t payload_size, uint8_t message_type, MessageEncodeFn encode_fn, const void *msg); + bool send_message_(uint32_t payload_size, uint16_t message_type, MessageEncodeFn encode_fn, const void *msg); // Core batch encoding logic. ALWAYS_INLINE so encode_fn devirtualizes at hot call sites. // Defined in api_connection_buffer.h (needs APIServer complete). @@ -664,10 +666,9 @@ class APIConnection final : public APIServerConnectionBase { struct BatchItem { EntityBase *entity; // 4 bytes - Entity pointer - uint8_t message_type; // 1 byte - Message type for protocol and dispatch + uint16_t message_type; // 2 bytes - Message type for protocol and dispatch uint8_t estimated_size; // 1 byte - Estimated message size (max 255 bytes) uint8_t aux_data_index{AUX_DATA_UNUSED}; // 1 byte - For events: index into entity's event_types - // 1 byte padding }; std::vector items; @@ -677,7 +678,7 @@ class APIConnection final : public APIServerConnectionBase { // connections that do, buffers are released after initial sync anyway // Add item to the batch (with deduplication) - void add_item(EntityBase *entity, uint8_t message_type, uint8_t estimated_size, + void add_item(EntityBase *entity, uint16_t message_type, uint8_t estimated_size, uint8_t aux_data_index = AUX_DATA_UNUSED) { // Dedup: O(n) scan but optimized for RAM over performance // Skip deduplication for events - they are edge-triggered, every occurrence matters @@ -693,7 +694,7 @@ class APIConnection final : public APIServerConnectionBase { this->items.push_back({entity, message_type, estimated_size, aux_data_index}); } // Add item to the front of the batch (for high priority messages like ping) - void add_item_front(EntityBase *entity, uint8_t message_type, uint8_t estimated_size) { + void add_item_front(EntityBase *entity, uint16_t message_type, uint8_t estimated_size) { // Swap to front avoids expensive vector::insert which shifts all elements this->items.push_back({entity, message_type, estimated_size, AUX_DATA_UNUSED}); if (this->items.size() > 1) { @@ -758,13 +759,15 @@ class APIConnection final : public APIServerConnectionBase { #endif } flags_{}; // 2 bytes total - // 2-byte types immediately after flags_ (no padding between them) - uint16_t client_api_version_major_{0}; - uint16_t client_api_version_minor_{0}; + // 2-byte type immediately after flags_ (no padding between them) + uint16_t batch_message_type_{0}; // Current message type during batch encoding // 1-byte types to fill remaining space before next 4-byte boundary + // Client API versions are clamped to 255 on receive (see send_hello_response_) + uint8_t client_api_version_major_{0}; + uint8_t client_api_version_minor_{0}; ActiveIterator active_iterator_{ActiveIterator::NONE}; - uint8_t batch_message_type_{0}; // Current message type during batch encoding - // Total: 2 (flags) + 2 + 2 + 1 + 1 = 8 bytes, aligned to 4-byte boundary + // Total: 2 (flags) + 2 + 1 + 1 + 1 + 1 (batch_header_size_ below) = 8 bytes, + // aligned to 4-byte boundary // Actual header size used by encode_to_buffer for the current message. // Read by process_batch_multi_ to pass into MessageInfo. @@ -813,7 +816,7 @@ class APIConnection final : public APIServerConnectionBase { // 2. It's an EventResponse (events are edge-triggered - every occurrence matters) // 3. OR: User has opted into immediate sending (should_try_send_immediately = true // AND batch_delay = 0) - inline bool should_send_immediately_(uint8_t message_type) const { + inline bool should_send_immediately_(uint16_t message_type) const { return ( #ifdef USE_UPDATE message_type == UpdateStateResponse::MESSAGE_TYPE || @@ -827,11 +830,11 @@ class APIConnection final : public APIServerConnectionBase { // Helper method to send a message either immediately or via batching // Tries immediate send if should_send_immediately_() returns true and buffer has space // Falls back to batching if immediate send fails or isn't applicable - bool send_message_smart_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size, + bool send_message_smart_(EntityBase *entity, uint16_t message_type, uint8_t estimated_size, uint8_t aux_data_index = DeferredBatch::AUX_DATA_UNUSED); // Helper function to schedule a deferred message with known message type - bool schedule_message_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size, + bool schedule_message_(EntityBase *entity, uint16_t message_type, uint8_t estimated_size, uint8_t aux_data_index = DeferredBatch::AUX_DATA_UNUSED) { this->deferred_batch_.add_item(entity, message_type, estimated_size, aux_data_index); return this->schedule_batch_(); @@ -839,7 +842,7 @@ class APIConnection final : public APIServerConnectionBase { // Helper function to schedule a high priority message at the front of the batch // Out-of-line: callers (on_shutdown, check_keepalive_) are cold paths - bool schedule_message_front_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size); + bool schedule_message_front_(EntityBase *entity, uint16_t message_type, uint8_t estimated_size); // Helper function to log client messages with name and peername void log_client_(int level, const LogString *message); diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 1c60bb87a5..ff8aa7834c 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -49,16 +49,16 @@ struct ReadPacketBuffer { }; // Packed message info structure to minimize memory usage -// Note: message_type is uint8_t — all current protobuf message types fit in 8 bits. -// The noise wire format encodes types as 16-bit, but the high byte is always 0. -// If message types ever exceed 255, this and encrypt_noise_message_ must be updated. +// message_type matches the wire formats: noise carries a fixed 16-bit type +// field, plaintext a type varint. The proto codegen caps message IDs at 16383 +// so the plaintext type varint fits the 2 bytes budgeted in HEADER_PADDING. struct MessageInfo { uint16_t offset; // Offset in buffer where message starts uint16_t payload_size; // Size of the message payload - uint8_t message_type; // Message type (0-255) + uint16_t message_type; // Message type (0-16383) uint8_t header_size; // Actual header size used (avoids recomputation in write path) - MessageInfo(uint8_t type, uint16_t off, uint16_t size, uint8_t hdr) + MessageInfo(uint16_t type, uint16_t off, uint16_t size, uint8_t hdr) : offset(off), payload_size(size), message_type(type), header_size(hdr) {} }; @@ -173,7 +173,7 @@ class APIFrameHelper { } // Write a single protobuf message - the hot path (87-100% of all writes). // Caller must ensure state is DATA before calling. - virtual APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) = 0; + virtual APIError write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) = 0; // Write multiple protobuf messages in a single batched operation. // Caller must ensure state is DATA and messages is not empty. // messages contains (message_type, offset, length) for each message in the buffer. @@ -187,15 +187,15 @@ class APIFrameHelper { // Distinguishes protocols via frame_footer_size_ (noise always has a non-zero MAC // footer, plaintext has footer=0). If a protocol with a plaintext footer is ever // added, this should become a virtual method. - uint8_t frame_header_size(uint16_t payload_size, uint8_t message_type) const { + uint8_t frame_header_size(uint16_t payload_size, uint16_t message_type) const { #if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT) return this->frame_footer_size_ ? this->frame_header_padding_ - : static_cast(1 + ProtoSize::varint16(payload_size) + ProtoSize::varint8(message_type)); + : static_cast(1 + ProtoSize::varint16(payload_size) + ProtoSize::varint16(message_type)); #elif defined(USE_API_NOISE) return this->frame_header_padding_; #else // USE_API_PLAINTEXT only - return static_cast(1 + ProtoSize::varint16(payload_size) + ProtoSize::varint8(message_type)); + return static_cast(1 + ProtoSize::varint16(payload_size) + ProtoSize::varint16(message_type)); #endif } // Get the frame footer size required by this protocol diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index d7554e62c5..9c4cc2aa78 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -442,7 +442,7 @@ APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) { } // Encrypt a single noise message in place and return the encrypted frame length. // Returns APIError::OK on success. -APIError APINoiseFrameHelper::encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint8_t message_type, +APIError APINoiseFrameHelper::encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint16_t message_type, uint16_t &encrypted_len_out) { // The noise frame header is written after encryption, when the size is known @@ -472,7 +472,7 @@ APIError APINoiseFrameHelper::encrypt_noise_message_(uint8_t *buf_start, uint16_ return APIError::OK; } -APIError APINoiseFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) { +APIError APINoiseFrameHelper::write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) { #ifdef ESPHOME_DEBUG_API assert(this->state_ == State::DATA); #endif diff --git a/esphome/components/api/api_frame_helper_noise.h b/esphome/components/api/api_frame_helper_noise.h index 05060c77de..366751738e 100644 --- a/esphome/components/api/api_frame_helper_noise.h +++ b/esphome/components/api/api_frame_helper_noise.h @@ -31,7 +31,7 @@ class APINoiseFrameHelper final : public APIFrameHelper { #endif APIError loop() override; APIError read_packet(ReadPacketBuffer *buffer) override; - APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override; + APIError write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) override; APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) override; protected: @@ -44,7 +44,7 @@ class APINoiseFrameHelper final : public APIFrameHelper { APIError state_action_handshake_write_(); APIError try_read_frame_(); APIError write_frame_(const uint8_t *data, uint16_t len); - APIError encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint8_t message_type, + APIError encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint16_t message_type, uint16_t &encrypted_len_out); APIError init_handshake_(); APIError check_handshake_finished_(); diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index 9359f568fb..09ace7294a 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -5,6 +5,7 @@ #include "esphome/core/hal.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "api_pb2.h" #include "proto.h" #include #include @@ -252,24 +253,21 @@ ESPHOME_ALWAYS_INLINE static inline void encode_varint_16(uint16_t value, uint8_ *p = static_cast(value); } -// Encode an 8-bit varint (1-2 bytes) using pre-computed length. -ESPHOME_ALWAYS_INLINE static inline void encode_varint_8(uint8_t value, uint8_t varint_len, uint8_t *p) { - if (varint_len == 2) { - *p++ = static_cast(value | 0x80); - *p = static_cast(value >> 7); - } else { - *p = value; - } -} +// The generator rejects message IDs above MAX_MESSAGE_TYPE, so the type varint +// can never outgrow the 2 bytes HEADER_PADDING budgets for it. Without this +// bound, write_plaintext_header's header_offset would underflow for the first +// message in a batch and the header write would land outside the buffer. +static_assert(1 + 3 + ProtoSize::varint16(MAX_MESSAGE_TYPE) <= APIPlaintextFrameHelper::HEADER_PADDING, + "HEADER_PADDING cannot fit the type varint of the largest message ID"); // Write plaintext header into pre-allocated padding before payload. // padding_size: bytes reserved before payload (HEADER_PADDING for first/single msg, // actual header size for contiguous batch messages). // Returns the total header length (indicator + varints). ESPHOME_ALWAYS_INLINE static inline uint8_t write_plaintext_header(uint8_t *buf_start, uint16_t payload_size, - uint8_t message_type, uint8_t padding_size) { + uint16_t message_type, uint8_t padding_size) { uint8_t size_varint_len = ProtoSize::varint16(payload_size); - uint8_t type_varint_len = ProtoSize::varint8(message_type); + uint8_t type_varint_len = ProtoSize::varint16(message_type); uint8_t total_header_len = 1 + size_varint_len + type_varint_len; // The header is right-justified within the padding so it sits immediately before payload. @@ -292,12 +290,12 @@ ESPHOME_ALWAYS_INLINE static inline uint8_t write_plaintext_header(uint8_t *buf_ // Encode varints directly into buffer using pre-computed lengths encode_varint_16(payload_size, size_varint_len, buf_start + header_offset + 1); - encode_varint_8(message_type, type_varint_len, buf_start + header_offset + 1 + size_varint_len); + encode_varint_16(message_type, type_varint_len, buf_start + header_offset + 1 + size_varint_len); return total_header_len; } -APIError APIPlaintextFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) { +APIError APIPlaintextFrameHelper::write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) { #ifdef ESPHOME_DEBUG_API assert(this->state_ == State::DATA); #endif diff --git a/esphome/components/api/api_frame_helper_plaintext.h b/esphome/components/api/api_frame_helper_plaintext.h index ea3f6d7280..00e7c7b1bc 100644 --- a/esphome/components/api/api_frame_helper_plaintext.h +++ b/esphome/components/api/api_frame_helper_plaintext.h @@ -10,7 +10,8 @@ class APIPlaintextFrameHelper final : public APIFrameHelper { // Plaintext header structure (worst case): // Pos 0: indicator (0x00) // Pos 1-3: payload size varint (up to 3 bytes) - // Pos 4-5: message type varint (up to 2 bytes) + // Pos 4-5: message type varint (up to 2 bytes; covers message IDs up to + // 16383, enforced by the proto codegen) // Pos 6+: actual payload data static constexpr uint8_t HEADER_PADDING = 1 + 3 + 2; // indicator + size varint + type varint @@ -21,7 +22,7 @@ class APIPlaintextFrameHelper final : public APIFrameHelper { APIError init() override; APIError loop() override; APIError read_packet(ReadPacketBuffer *buffer) override; - APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override; + APIError write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) override; APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) override; #ifdef USE_API_NOISE // After try_read_frame_ returned PROTOCOL_SWITCH_TO_NOISE: copy out the diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index cd2f32deaf..48e277fce1 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -9,6 +9,10 @@ namespace esphome::api { +// Upper bound on message IDs, enforced by the code generator: the plaintext +// frame header budgets 2 varint bytes for the type (HEADER_PADDING). +static constexpr uint16_t MAX_MESSAGE_TYPE = 16383; + namespace enums { enum DisconnectReason : uint32_t { @@ -407,7 +411,7 @@ class CommandProtoMessage : public ProtoDecodableMessage { }; class HelloRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 1; + static constexpr uint16_t MESSAGE_TYPE = 1; static constexpr uint8_t ESTIMATED_SIZE = 17; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("hello_request"); } @@ -425,7 +429,7 @@ class HelloRequest final : public ProtoDecodableMessage { }; class HelloResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 2; + static constexpr uint16_t MESSAGE_TYPE = 2; static constexpr uint8_t ESTIMATED_SIZE = 26; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("hello_response"); } @@ -444,7 +448,7 @@ class HelloResponse final : public ProtoMessage { }; class DisconnectRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 5; + static constexpr uint16_t MESSAGE_TYPE = 5; static constexpr uint8_t ESTIMATED_SIZE = 2; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("disconnect_request"); } @@ -461,7 +465,7 @@ class DisconnectRequest final : public ProtoDecodableMessage { }; class DisconnectResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 6; + static constexpr uint16_t MESSAGE_TYPE = 6; static constexpr uint8_t ESTIMATED_SIZE = 0; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("disconnect_response"); } @@ -474,7 +478,7 @@ class DisconnectResponse final : public ProtoMessage { }; class PingRequest final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 7; + static constexpr uint16_t MESSAGE_TYPE = 7; static constexpr uint8_t ESTIMATED_SIZE = 0; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("ping_request"); } @@ -487,7 +491,7 @@ class PingRequest final : public ProtoMessage { }; class PingResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 8; + static constexpr uint16_t MESSAGE_TYPE = 8; static constexpr uint8_t ESTIMATED_SIZE = 0; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("ping_response"); } @@ -544,7 +548,7 @@ class SerialProxyInfo final : public ProtoMessage { #endif class DeviceInfoResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 10; + static constexpr uint16_t MESSAGE_TYPE = 10; static constexpr uint16_t ESTIMATED_SIZE = 312; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("device_info_response"); } @@ -655,7 +659,7 @@ class ZWaveProxyCapabilities final : public ProtoMessage { #endif class DeviceCapabilitiesResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 150; + static constexpr uint16_t MESSAGE_TYPE = 150; static constexpr uint8_t ESTIMATED_SIZE = 102; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("device_capabilities_response"); } @@ -682,7 +686,7 @@ class DeviceCapabilitiesResponse final : public ProtoMessage { }; class ListEntitiesDoneResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 19; + static constexpr uint16_t MESSAGE_TYPE = 19; static constexpr uint8_t ESTIMATED_SIZE = 0; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_done_response"); } @@ -696,7 +700,7 @@ class ListEntitiesDoneResponse final : public ProtoMessage { #ifdef USE_BINARY_SENSOR class ListEntitiesBinarySensorResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 12; + static constexpr uint16_t MESSAGE_TYPE = 12; static constexpr uint8_t ESTIMATED_SIZE = 51; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_binary_sensor_response"); } @@ -713,7 +717,7 @@ class ListEntitiesBinarySensorResponse final : public InfoResponseProtoMessage { }; class BinarySensorStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 21; + static constexpr uint16_t MESSAGE_TYPE = 21; static constexpr uint8_t ESTIMATED_SIZE = 13; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("binary_sensor_state_response"); } @@ -732,7 +736,7 @@ class BinarySensorStateResponse final : public StateResponseProtoMessage { #ifdef USE_COVER class ListEntitiesCoverResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 13; + static constexpr uint16_t MESSAGE_TYPE = 13; static constexpr uint8_t ESTIMATED_SIZE = 57; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_cover_response"); } @@ -752,7 +756,7 @@ class ListEntitiesCoverResponse final : public InfoResponseProtoMessage { }; class CoverStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 22; + static constexpr uint16_t MESSAGE_TYPE = 22; static constexpr uint8_t ESTIMATED_SIZE = 21; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("cover_state_response"); } @@ -770,7 +774,7 @@ class CoverStateResponse final : public StateResponseProtoMessage { }; class CoverCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 30; + static constexpr uint16_t MESSAGE_TYPE = 30; static constexpr uint8_t ESTIMATED_SIZE = 25; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("cover_command_request"); } @@ -792,7 +796,7 @@ class CoverCommandRequest final : public CommandProtoMessage { #ifdef USE_FAN class ListEntitiesFanResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 14; + static constexpr uint16_t MESSAGE_TYPE = 14; static constexpr uint8_t ESTIMATED_SIZE = 68; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_fan_response"); } @@ -812,7 +816,7 @@ class ListEntitiesFanResponse final : public InfoResponseProtoMessage { }; class FanStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 23; + static constexpr uint16_t MESSAGE_TYPE = 23; static constexpr uint8_t ESTIMATED_SIZE = 28; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("fan_state_response"); } @@ -832,7 +836,7 @@ class FanStateResponse final : public StateResponseProtoMessage { }; class FanCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 31; + static constexpr uint16_t MESSAGE_TYPE = 31; static constexpr uint8_t ESTIMATED_SIZE = 38; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("fan_command_request"); } @@ -860,7 +864,7 @@ class FanCommandRequest final : public CommandProtoMessage { #ifdef USE_LIGHT class ListEntitiesLightResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 15; + static constexpr uint16_t MESSAGE_TYPE = 15; static constexpr uint8_t ESTIMATED_SIZE = 73; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_light_response"); } @@ -879,7 +883,7 @@ class ListEntitiesLightResponse final : public InfoResponseProtoMessage { }; class LightStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 24; + static constexpr uint16_t MESSAGE_TYPE = 24; static constexpr uint8_t ESTIMATED_SIZE = 67; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("light_state_response"); } @@ -906,7 +910,7 @@ class LightStateResponse final : public StateResponseProtoMessage { }; class LightCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 32; + static constexpr uint16_t MESSAGE_TYPE = 32; static constexpr uint8_t ESTIMATED_SIZE = 112; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("light_command_request"); } @@ -950,7 +954,7 @@ class LightCommandRequest final : public CommandProtoMessage { #ifdef USE_SENSOR class ListEntitiesSensorResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 16; + static constexpr uint16_t MESSAGE_TYPE = 16; static constexpr uint8_t ESTIMATED_SIZE = 66; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_sensor_response"); } @@ -970,7 +974,7 @@ class ListEntitiesSensorResponse final : public InfoResponseProtoMessage { }; class SensorStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 25; + static constexpr uint16_t MESSAGE_TYPE = 25; static constexpr uint8_t ESTIMATED_SIZE = 16; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("sensor_state_response"); } @@ -989,7 +993,7 @@ class SensorStateResponse final : public StateResponseProtoMessage { #ifdef USE_SWITCH class ListEntitiesSwitchResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 17; + static constexpr uint16_t MESSAGE_TYPE = 17; static constexpr uint8_t ESTIMATED_SIZE = 51; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_switch_response"); } @@ -1006,7 +1010,7 @@ class ListEntitiesSwitchResponse final : public InfoResponseProtoMessage { }; class SwitchStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 26; + static constexpr uint16_t MESSAGE_TYPE = 26; static constexpr uint8_t ESTIMATED_SIZE = 11; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("switch_state_response"); } @@ -1022,7 +1026,7 @@ class SwitchStateResponse final : public StateResponseProtoMessage { }; class SwitchCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 33; + static constexpr uint16_t MESSAGE_TYPE = 33; static constexpr uint8_t ESTIMATED_SIZE = 11; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("switch_command_request"); } @@ -1040,7 +1044,7 @@ class SwitchCommandRequest final : public CommandProtoMessage { #ifdef USE_TEXT_SENSOR class ListEntitiesTextSensorResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 18; + static constexpr uint16_t MESSAGE_TYPE = 18; static constexpr uint8_t ESTIMATED_SIZE = 49; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_text_sensor_response"); } @@ -1056,7 +1060,7 @@ class ListEntitiesTextSensorResponse final : public InfoResponseProtoMessage { }; class TextSensorStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 27; + static constexpr uint16_t MESSAGE_TYPE = 27; static constexpr uint8_t ESTIMATED_SIZE = 20; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("text_sensor_state_response"); } @@ -1074,7 +1078,7 @@ class TextSensorStateResponse final : public StateResponseProtoMessage { #endif class SubscribeLogsRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 28; + static constexpr uint16_t MESSAGE_TYPE = 28; static constexpr uint8_t ESTIMATED_SIZE = 4; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("subscribe_logs_request"); } @@ -1090,7 +1094,7 @@ class SubscribeLogsRequest final : public ProtoDecodableMessage { }; class SubscribeLogsResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 29; + static constexpr uint16_t MESSAGE_TYPE = 29; static constexpr uint8_t ESTIMATED_SIZE = 21; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("subscribe_logs_response"); } @@ -1113,7 +1117,7 @@ class SubscribeLogsResponse final : public ProtoMessage { #ifdef USE_API_NOISE class NoiseEncryptionSetKeyRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 124; + static constexpr uint16_t MESSAGE_TYPE = 124; static constexpr uint8_t ESTIMATED_SIZE = 19; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("noise_encryption_set_key_request"); } @@ -1129,7 +1133,7 @@ class NoiseEncryptionSetKeyRequest final : public ProtoDecodableMessage { }; class NoiseEncryptionSetKeyResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 125; + static constexpr uint16_t MESSAGE_TYPE = 125; static constexpr uint8_t ESTIMATED_SIZE = 2; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("noise_encryption_set_key_response"); } @@ -1159,7 +1163,7 @@ class HomeassistantServiceMap final : public ProtoMessage { }; class HomeassistantActionRequest final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 35; + static constexpr uint16_t MESSAGE_TYPE = 35; static constexpr uint8_t ESTIMATED_SIZE = 128; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("homeassistant_action_request"); } @@ -1190,7 +1194,7 @@ class HomeassistantActionRequest final : public ProtoMessage { #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES class HomeassistantActionResponse final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 130; + static constexpr uint16_t MESSAGE_TYPE = 130; static constexpr uint8_t ESTIMATED_SIZE = 34; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("homeassistant_action_response"); } @@ -1214,7 +1218,7 @@ class HomeassistantActionResponse final : public ProtoDecodableMessage { #ifdef USE_API_HOMEASSISTANT_STATES class SubscribeHomeAssistantStateResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 39; + static constexpr uint16_t MESSAGE_TYPE = 39; static constexpr uint8_t ESTIMATED_SIZE = 20; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("subscribe_home_assistant_state_response"); } @@ -1232,7 +1236,7 @@ class SubscribeHomeAssistantStateResponse final : public ProtoMessage { }; class HomeAssistantStateResponse final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 40; + static constexpr uint16_t MESSAGE_TYPE = 40; static constexpr uint8_t ESTIMATED_SIZE = 27; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("home_assistant_state_response"); } @@ -1250,7 +1254,7 @@ class HomeAssistantStateResponse final : public ProtoDecodableMessage { #endif class GetTimeRequest final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 36; + static constexpr uint16_t MESSAGE_TYPE = 36; static constexpr uint8_t ESTIMATED_SIZE = 0; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("get_time_request"); } @@ -1292,7 +1296,7 @@ class ParsedTimezone final : public ProtoDecodableMessage { }; class GetTimeResponse final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 37; + static constexpr uint16_t MESSAGE_TYPE = 37; static constexpr uint8_t ESTIMATED_SIZE = 22; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("get_time_response"); } @@ -1323,7 +1327,7 @@ class ListEntitiesServicesArgument final : public ProtoMessage { }; class ListEntitiesServicesResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 41; + static constexpr uint16_t MESSAGE_TYPE = 41; static constexpr uint8_t ESTIMATED_SIZE = 50; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_services_response"); } @@ -1363,7 +1367,7 @@ class ExecuteServiceArgument final : public ProtoDecodableMessage { }; class ExecuteServiceRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 42; + static constexpr uint16_t MESSAGE_TYPE = 42; static constexpr uint8_t ESTIMATED_SIZE = 45; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("execute_service_request"); } @@ -1390,7 +1394,7 @@ class ExecuteServiceRequest final : public ProtoDecodableMessage { #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES class ExecuteServiceResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 131; + static constexpr uint16_t MESSAGE_TYPE = 131; static constexpr uint8_t ESTIMATED_SIZE = 34; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("execute_service_response"); } @@ -1414,7 +1418,7 @@ class ExecuteServiceResponse final : public ProtoMessage { #ifdef USE_CAMERA class ListEntitiesCameraResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 43; + static constexpr uint16_t MESSAGE_TYPE = 43; static constexpr uint8_t ESTIMATED_SIZE = 40; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_camera_response"); } @@ -1429,7 +1433,7 @@ class ListEntitiesCameraResponse final : public InfoResponseProtoMessage { }; class CameraImageResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 44; + static constexpr uint16_t MESSAGE_TYPE = 44; static constexpr uint8_t ESTIMATED_SIZE = 30; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("camera_image_response"); } @@ -1451,7 +1455,7 @@ class CameraImageResponse final : public StateResponseProtoMessage { }; class CameraImageRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 45; + static constexpr uint16_t MESSAGE_TYPE = 45; static constexpr uint8_t ESTIMATED_SIZE = 4; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("camera_image_request"); } @@ -1469,7 +1473,7 @@ class CameraImageRequest final : public ProtoDecodableMessage { #ifdef USE_CLIMATE class ListEntitiesClimateResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 46; + static constexpr uint16_t MESSAGE_TYPE = 46; 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"); } @@ -1503,7 +1507,7 @@ class ListEntitiesClimateResponse final : public InfoResponseProtoMessage { }; class ClimateStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 47; + static constexpr uint16_t MESSAGE_TYPE = 47; static constexpr uint8_t ESTIMATED_SIZE = 68; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("climate_state_response"); } @@ -1531,7 +1535,7 @@ class ClimateStateResponse final : public StateResponseProtoMessage { }; class ClimateCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 48; + static constexpr uint16_t MESSAGE_TYPE = 48; static constexpr uint8_t ESTIMATED_SIZE = 84; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("climate_command_request"); } @@ -1569,7 +1573,7 @@ class ClimateCommandRequest final : public CommandProtoMessage { #ifdef USE_WATER_HEATER class ListEntitiesWaterHeaterResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 132; + static constexpr uint16_t MESSAGE_TYPE = 132; 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"); } @@ -1590,7 +1594,7 @@ class ListEntitiesWaterHeaterResponse final : public InfoResponseProtoMessage { }; class WaterHeaterStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 133; + static constexpr uint16_t MESSAGE_TYPE = 133; static constexpr uint8_t ESTIMATED_SIZE = 35; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("water_heater_state_response"); } @@ -1611,7 +1615,7 @@ class WaterHeaterStateResponse final : public StateResponseProtoMessage { }; class WaterHeaterCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 134; + static constexpr uint16_t MESSAGE_TYPE = 134; static constexpr uint8_t ESTIMATED_SIZE = 34; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("water_heater_command_request"); } @@ -1634,7 +1638,7 @@ class WaterHeaterCommandRequest final : public CommandProtoMessage { #ifdef USE_NUMBER class ListEntitiesNumberResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 49; + static constexpr uint16_t MESSAGE_TYPE = 49; static constexpr uint8_t ESTIMATED_SIZE = 75; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_number_response"); } @@ -1655,7 +1659,7 @@ class ListEntitiesNumberResponse final : public InfoResponseProtoMessage { }; class NumberStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 50; + static constexpr uint16_t MESSAGE_TYPE = 50; static constexpr uint8_t ESTIMATED_SIZE = 16; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("number_state_response"); } @@ -1672,7 +1676,7 @@ class NumberStateResponse final : public StateResponseProtoMessage { }; class NumberCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 51; + static constexpr uint16_t MESSAGE_TYPE = 51; static constexpr uint8_t ESTIMATED_SIZE = 14; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("number_command_request"); } @@ -1690,7 +1694,7 @@ class NumberCommandRequest final : public CommandProtoMessage { #ifdef USE_SELECT class ListEntitiesSelectResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 52; + static constexpr uint16_t MESSAGE_TYPE = 52; static constexpr uint8_t ESTIMATED_SIZE = 58; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_select_response"); } @@ -1706,7 +1710,7 @@ class ListEntitiesSelectResponse final : public InfoResponseProtoMessage { }; class SelectStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 53; + static constexpr uint16_t MESSAGE_TYPE = 53; static constexpr uint8_t ESTIMATED_SIZE = 20; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("select_state_response"); } @@ -1723,7 +1727,7 @@ class SelectStateResponse final : public StateResponseProtoMessage { }; class SelectCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 54; + static constexpr uint16_t MESSAGE_TYPE = 54; static constexpr uint8_t ESTIMATED_SIZE = 18; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("select_command_request"); } @@ -1742,7 +1746,7 @@ class SelectCommandRequest final : public CommandProtoMessage { #ifdef USE_SIREN class ListEntitiesSirenResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 55; + static constexpr uint16_t MESSAGE_TYPE = 55; static constexpr uint8_t ESTIMATED_SIZE = 62; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_siren_response"); } @@ -1760,7 +1764,7 @@ class ListEntitiesSirenResponse final : public InfoResponseProtoMessage { }; class SirenStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 56; + static constexpr uint16_t MESSAGE_TYPE = 56; static constexpr uint8_t ESTIMATED_SIZE = 11; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("siren_state_response"); } @@ -1776,7 +1780,7 @@ class SirenStateResponse final : public StateResponseProtoMessage { }; class SirenCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 57; + static constexpr uint16_t MESSAGE_TYPE = 57; static constexpr uint8_t ESTIMATED_SIZE = 37; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("siren_command_request"); } @@ -1802,7 +1806,7 @@ class SirenCommandRequest final : public CommandProtoMessage { #ifdef USE_LOCK class ListEntitiesLockResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 58; + static constexpr uint16_t MESSAGE_TYPE = 58; static constexpr uint8_t ESTIMATED_SIZE = 55; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_lock_response"); } @@ -1821,7 +1825,7 @@ class ListEntitiesLockResponse final : public InfoResponseProtoMessage { }; class LockStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 59; + static constexpr uint16_t MESSAGE_TYPE = 59; static constexpr uint8_t ESTIMATED_SIZE = 11; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("lock_state_response"); } @@ -1837,7 +1841,7 @@ class LockStateResponse final : public StateResponseProtoMessage { }; class LockCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 60; + static constexpr uint16_t MESSAGE_TYPE = 60; static constexpr uint8_t ESTIMATED_SIZE = 22; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("lock_command_request"); } @@ -1858,7 +1862,7 @@ class LockCommandRequest final : public CommandProtoMessage { #ifdef USE_BUTTON class ListEntitiesButtonResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 61; + static constexpr uint16_t MESSAGE_TYPE = 61; static constexpr uint8_t ESTIMATED_SIZE = 49; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_button_response"); } @@ -1874,7 +1878,7 @@ class ListEntitiesButtonResponse final : public InfoResponseProtoMessage { }; class ButtonCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 62; + static constexpr uint16_t MESSAGE_TYPE = 62; static constexpr uint8_t ESTIMATED_SIZE = 9; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("button_command_request"); } @@ -1906,7 +1910,7 @@ class MediaPlayerSupportedFormat final : public ProtoMessage { }; class ListEntitiesMediaPlayerResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 63; + static constexpr uint16_t MESSAGE_TYPE = 63; static constexpr uint8_t ESTIMATED_SIZE = 80; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_media_player_response"); } @@ -1924,7 +1928,7 @@ class ListEntitiesMediaPlayerResponse final : public InfoResponseProtoMessage { }; class MediaPlayerStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 64; + static constexpr uint16_t MESSAGE_TYPE = 64; static constexpr uint8_t ESTIMATED_SIZE = 18; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("media_player_state_response"); } @@ -1942,7 +1946,7 @@ class MediaPlayerStateResponse final : public StateResponseProtoMessage { }; class MediaPlayerCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 65; + static constexpr uint16_t MESSAGE_TYPE = 65; static constexpr uint8_t ESTIMATED_SIZE = 35; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("media_player_command_request"); } @@ -1968,7 +1972,7 @@ class MediaPlayerCommandRequest final : public CommandProtoMessage { #ifdef USE_BLUETOOTH_PROXY class SubscribeBluetoothLEAdvertisementsRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 66; + static constexpr uint16_t MESSAGE_TYPE = 66; static constexpr uint8_t ESTIMATED_SIZE = 4; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("subscribe_bluetooth_le_advertisements_request"); } @@ -1996,7 +2000,7 @@ class BluetoothLERawAdvertisement final : public ProtoMessage { }; class BluetoothLERawAdvertisementsResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 93; + static constexpr uint16_t MESSAGE_TYPE = 93; static constexpr uint8_t ESTIMATED_SIZE = 136; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_le_raw_advertisements_response"); } @@ -2015,7 +2019,7 @@ class BluetoothLERawAdvertisementsResponse final : public ProtoMessage { #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS class BluetoothDeviceRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 68; + static constexpr uint16_t MESSAGE_TYPE = 68; static constexpr uint8_t ESTIMATED_SIZE = 12; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_device_request"); } @@ -2033,7 +2037,7 @@ class BluetoothDeviceRequest final : public ProtoDecodableMessage { }; class BluetoothDeviceConnectionResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 69; + static constexpr uint16_t MESSAGE_TYPE = 69; static constexpr uint8_t ESTIMATED_SIZE = 14; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_device_connection_response"); } @@ -2052,7 +2056,7 @@ class BluetoothDeviceConnectionResponse final : public ProtoMessage { }; class BluetoothGATTGetServicesRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 70; + static constexpr uint16_t MESSAGE_TYPE = 70; static constexpr uint8_t ESTIMATED_SIZE = 4; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_get_services_request"); } @@ -2109,7 +2113,7 @@ class BluetoothGATTService final : public ProtoMessage { }; class BluetoothGATTGetServicesResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 71; + static constexpr uint16_t MESSAGE_TYPE = 71; static constexpr uint8_t ESTIMATED_SIZE = 38; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_get_services_response"); } @@ -2126,7 +2130,7 @@ class BluetoothGATTGetServicesResponse final : public ProtoMessage { }; class BluetoothGATTGetServicesDoneResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 72; + static constexpr uint16_t MESSAGE_TYPE = 72; static constexpr uint8_t ESTIMATED_SIZE = 4; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_get_services_done_response"); } @@ -2142,7 +2146,7 @@ class BluetoothGATTGetServicesDoneResponse final : public ProtoMessage { }; class BluetoothGATTReadRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 73; + static constexpr uint16_t MESSAGE_TYPE = 73; static constexpr uint8_t ESTIMATED_SIZE = 8; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_read_request"); } @@ -2158,7 +2162,7 @@ class BluetoothGATTReadRequest final : public ProtoDecodableMessage { }; class BluetoothGATTReadResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 74; + static constexpr uint16_t MESSAGE_TYPE = 74; static constexpr uint8_t ESTIMATED_SIZE = 27; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_read_response"); } @@ -2181,7 +2185,7 @@ class BluetoothGATTReadResponse final : public ProtoMessage { }; class BluetoothGATTWriteRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 75; + static constexpr uint16_t MESSAGE_TYPE = 75; static constexpr uint8_t ESTIMATED_SIZE = 29; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_write_request"); } @@ -2201,7 +2205,7 @@ class BluetoothGATTWriteRequest final : public ProtoDecodableMessage { }; class BluetoothGATTReadDescriptorRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 76; + static constexpr uint16_t MESSAGE_TYPE = 76; static constexpr uint8_t ESTIMATED_SIZE = 8; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_read_descriptor_request"); } @@ -2217,7 +2221,7 @@ class BluetoothGATTReadDescriptorRequest final : public ProtoDecodableMessage { }; class BluetoothGATTWriteDescriptorRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 77; + static constexpr uint16_t MESSAGE_TYPE = 77; static constexpr uint8_t ESTIMATED_SIZE = 27; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_write_descriptor_request"); } @@ -2236,7 +2240,7 @@ class BluetoothGATTWriteDescriptorRequest final : public ProtoDecodableMessage { }; class BluetoothGATTNotifyRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 78; + static constexpr uint16_t MESSAGE_TYPE = 78; static constexpr uint8_t ESTIMATED_SIZE = 10; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_notify_request"); } @@ -2253,7 +2257,7 @@ class BluetoothGATTNotifyRequest final : public ProtoDecodableMessage { }; class BluetoothGATTNotifyDataResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 79; + static constexpr uint16_t MESSAGE_TYPE = 79; static constexpr uint8_t ESTIMATED_SIZE = 27; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_notify_data_response"); } @@ -2276,7 +2280,7 @@ class BluetoothGATTNotifyDataResponse final : public ProtoMessage { }; class BluetoothConnectionsFreeResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 81; + static constexpr uint16_t MESSAGE_TYPE = 81; static constexpr uint8_t ESTIMATED_SIZE = 20; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_connections_free_response"); } @@ -2294,7 +2298,7 @@ class BluetoothConnectionsFreeResponse final : public ProtoMessage { }; class BluetoothGATTErrorResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 82; + static constexpr uint16_t MESSAGE_TYPE = 82; static constexpr uint8_t ESTIMATED_SIZE = 12; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_error_response"); } @@ -2312,7 +2316,7 @@ class BluetoothGATTErrorResponse final : public ProtoMessage { }; class BluetoothGATTWriteResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 83; + static constexpr uint16_t MESSAGE_TYPE = 83; static constexpr uint8_t ESTIMATED_SIZE = 8; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_write_response"); } @@ -2329,7 +2333,7 @@ class BluetoothGATTWriteResponse final : public ProtoMessage { }; class BluetoothGATTNotifyResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 84; + static constexpr uint16_t MESSAGE_TYPE = 84; static constexpr uint8_t ESTIMATED_SIZE = 8; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_notify_response"); } @@ -2346,7 +2350,7 @@ class BluetoothGATTNotifyResponse final : public ProtoMessage { }; class BluetoothDevicePairingResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 85; + static constexpr uint16_t MESSAGE_TYPE = 85; static constexpr uint8_t ESTIMATED_SIZE = 10; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_device_pairing_response"); } @@ -2364,7 +2368,7 @@ class BluetoothDevicePairingResponse final : public ProtoMessage { }; class BluetoothDeviceUnpairingResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 86; + static constexpr uint16_t MESSAGE_TYPE = 86; static constexpr uint8_t ESTIMATED_SIZE = 10; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_device_unpairing_response"); } @@ -2382,7 +2386,7 @@ class BluetoothDeviceUnpairingResponse final : public ProtoMessage { }; class BluetoothDeviceClearCacheResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 88; + static constexpr uint16_t MESSAGE_TYPE = 88; static constexpr uint8_t ESTIMATED_SIZE = 10; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_device_clear_cache_response"); } @@ -2402,7 +2406,7 @@ class BluetoothDeviceClearCacheResponse final : public ProtoMessage { #ifdef USE_BLUETOOTH_PROXY class BluetoothScannerStateResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 126; + static constexpr uint16_t MESSAGE_TYPE = 126; static constexpr uint8_t ESTIMATED_SIZE = 6; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_scanner_state_response"); } @@ -2420,7 +2424,7 @@ class BluetoothScannerStateResponse final : public ProtoMessage { }; class BluetoothScannerSetModeRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 127; + static constexpr uint16_t MESSAGE_TYPE = 127; static constexpr uint8_t ESTIMATED_SIZE = 2; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_scanner_set_mode_request"); } @@ -2437,7 +2441,7 @@ class BluetoothScannerSetModeRequest final : public ProtoDecodableMessage { #ifdef USE_VOICE_ASSISTANT class SubscribeVoiceAssistantRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 89; + static constexpr uint16_t MESSAGE_TYPE = 89; static constexpr uint8_t ESTIMATED_SIZE = 6; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("subscribe_voice_assistant_request"); } @@ -2466,7 +2470,7 @@ class VoiceAssistantAudioSettings final : public ProtoMessage { }; class VoiceAssistantRequest final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 90; + static constexpr uint16_t MESSAGE_TYPE = 90; static constexpr uint8_t ESTIMATED_SIZE = 41; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("voice_assistant_request"); } @@ -2486,7 +2490,7 @@ class VoiceAssistantRequest final : public ProtoMessage { }; class VoiceAssistantResponse final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 91; + static constexpr uint16_t MESSAGE_TYPE = 91; static constexpr uint8_t ESTIMATED_SIZE = 6; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("voice_assistant_response"); } @@ -2513,7 +2517,7 @@ class VoiceAssistantEventData final : public ProtoDecodableMessage { }; class VoiceAssistantEventResponse final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 92; + static constexpr uint16_t MESSAGE_TYPE = 92; static constexpr uint8_t ESTIMATED_SIZE = 36; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("voice_assistant_event_response"); } @@ -2530,7 +2534,7 @@ class VoiceAssistantEventResponse final : public ProtoDecodableMessage { }; class VoiceAssistantAudio final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 106; + static constexpr uint16_t MESSAGE_TYPE = 106; static constexpr uint8_t ESTIMATED_SIZE = 40; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("voice_assistant_audio"); } @@ -2552,7 +2556,7 @@ class VoiceAssistantAudio final : public ProtoDecodableMessage { }; class VoiceAssistantTimerEventResponse final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 115; + static constexpr uint16_t MESSAGE_TYPE = 115; static constexpr uint8_t ESTIMATED_SIZE = 30; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("voice_assistant_timer_event_response"); } @@ -2573,7 +2577,7 @@ class VoiceAssistantTimerEventResponse final : public ProtoDecodableMessage { }; class VoiceAssistantAnnounceRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 119; + static constexpr uint16_t MESSAGE_TYPE = 119; static constexpr uint8_t ESTIMATED_SIZE = 29; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("voice_assistant_announce_request"); } @@ -2592,7 +2596,7 @@ class VoiceAssistantAnnounceRequest final : public ProtoDecodableMessage { }; class VoiceAssistantAnnounceFinished final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 120; + static constexpr uint16_t MESSAGE_TYPE = 120; static constexpr uint8_t ESTIMATED_SIZE = 2; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("voice_assistant_announce_finished"); } @@ -2638,7 +2642,7 @@ class VoiceAssistantExternalWakeWord final : public ProtoDecodableMessage { }; class VoiceAssistantConfigurationRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 121; + static constexpr uint16_t MESSAGE_TYPE = 121; static constexpr uint8_t ESTIMATED_SIZE = 34; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("voice_assistant_configuration_request"); } @@ -2653,7 +2657,7 @@ class VoiceAssistantConfigurationRequest final : public ProtoDecodableMessage { }; class VoiceAssistantConfigurationResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 122; + static constexpr uint16_t MESSAGE_TYPE = 122; static constexpr uint8_t ESTIMATED_SIZE = 56; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("voice_assistant_configuration_response"); } @@ -2671,7 +2675,7 @@ class VoiceAssistantConfigurationResponse final : public ProtoMessage { }; class VoiceAssistantSetConfiguration final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 123; + static constexpr uint16_t MESSAGE_TYPE = 123; static constexpr uint8_t ESTIMATED_SIZE = 18; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("voice_assistant_set_configuration"); } @@ -2688,7 +2692,7 @@ class VoiceAssistantSetConfiguration final : public ProtoDecodableMessage { #ifdef USE_ALARM_CONTROL_PANEL class ListEntitiesAlarmControlPanelResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 94; + static constexpr uint16_t MESSAGE_TYPE = 94; static constexpr uint8_t ESTIMATED_SIZE = 48; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_alarm_control_panel_response"); } @@ -2706,7 +2710,7 @@ class ListEntitiesAlarmControlPanelResponse final : public InfoResponseProtoMess }; class AlarmControlPanelStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 95; + static constexpr uint16_t MESSAGE_TYPE = 95; static constexpr uint8_t ESTIMATED_SIZE = 11; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("alarm_control_panel_state_response"); } @@ -2722,7 +2726,7 @@ class AlarmControlPanelStateResponse final : public StateResponseProtoMessage { }; class AlarmControlPanelCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 96; + static constexpr uint16_t MESSAGE_TYPE = 96; static constexpr uint8_t ESTIMATED_SIZE = 20; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("alarm_control_panel_command_request"); } @@ -2742,7 +2746,7 @@ class AlarmControlPanelCommandRequest final : public CommandProtoMessage { #ifdef USE_TEXT class ListEntitiesTextResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 97; + static constexpr uint16_t MESSAGE_TYPE = 97; static constexpr uint8_t ESTIMATED_SIZE = 59; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_text_response"); } @@ -2761,7 +2765,7 @@ class ListEntitiesTextResponse final : public InfoResponseProtoMessage { }; class TextStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 98; + static constexpr uint16_t MESSAGE_TYPE = 98; static constexpr uint8_t ESTIMATED_SIZE = 20; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("text_state_response"); } @@ -2778,7 +2782,7 @@ class TextStateResponse final : public StateResponseProtoMessage { }; class TextCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 99; + static constexpr uint16_t MESSAGE_TYPE = 99; static constexpr uint8_t ESTIMATED_SIZE = 18; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("text_command_request"); } @@ -2797,7 +2801,7 @@ class TextCommandRequest final : public CommandProtoMessage { #ifdef USE_DATETIME_DATE class ListEntitiesDateResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 100; + static constexpr uint16_t MESSAGE_TYPE = 100; static constexpr uint8_t ESTIMATED_SIZE = 40; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_date_response"); } @@ -2812,7 +2816,7 @@ class ListEntitiesDateResponse final : public InfoResponseProtoMessage { }; class DateStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 101; + static constexpr uint16_t MESSAGE_TYPE = 101; static constexpr uint8_t ESTIMATED_SIZE = 23; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("date_state_response"); } @@ -2831,7 +2835,7 @@ class DateStateResponse final : public StateResponseProtoMessage { }; class DateCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 102; + static constexpr uint16_t MESSAGE_TYPE = 102; static constexpr uint8_t ESTIMATED_SIZE = 21; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("date_command_request"); } @@ -2851,7 +2855,7 @@ class DateCommandRequest final : public CommandProtoMessage { #ifdef USE_DATETIME_TIME class ListEntitiesTimeResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 103; + static constexpr uint16_t MESSAGE_TYPE = 103; static constexpr uint8_t ESTIMATED_SIZE = 40; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_time_response"); } @@ -2866,7 +2870,7 @@ class ListEntitiesTimeResponse final : public InfoResponseProtoMessage { }; class TimeStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 104; + static constexpr uint16_t MESSAGE_TYPE = 104; static constexpr uint8_t ESTIMATED_SIZE = 23; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("time_state_response"); } @@ -2885,7 +2889,7 @@ class TimeStateResponse final : public StateResponseProtoMessage { }; class TimeCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 105; + static constexpr uint16_t MESSAGE_TYPE = 105; static constexpr uint8_t ESTIMATED_SIZE = 21; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("time_command_request"); } @@ -2905,7 +2909,7 @@ class TimeCommandRequest final : public CommandProtoMessage { #ifdef USE_EVENT class ListEntitiesEventResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 107; + static constexpr uint16_t MESSAGE_TYPE = 107; static constexpr uint8_t ESTIMATED_SIZE = 67; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_event_response"); } @@ -2922,7 +2926,7 @@ class ListEntitiesEventResponse final : public InfoResponseProtoMessage { }; class EventResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 108; + static constexpr uint16_t MESSAGE_TYPE = 108; static constexpr uint8_t ESTIMATED_SIZE = 18; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("event_response"); } @@ -2940,7 +2944,7 @@ class EventResponse final : public StateResponseProtoMessage { #ifdef USE_VALVE class ListEntitiesValveResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 109; + static constexpr uint16_t MESSAGE_TYPE = 109; static constexpr uint8_t ESTIMATED_SIZE = 55; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_valve_response"); } @@ -2959,7 +2963,7 @@ class ListEntitiesValveResponse final : public InfoResponseProtoMessage { }; class ValveStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 110; + static constexpr uint16_t MESSAGE_TYPE = 110; static constexpr uint8_t ESTIMATED_SIZE = 16; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("valve_state_response"); } @@ -2976,7 +2980,7 @@ class ValveStateResponse final : public StateResponseProtoMessage { }; class ValveCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 111; + static constexpr uint16_t MESSAGE_TYPE = 111; static constexpr uint8_t ESTIMATED_SIZE = 18; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("valve_command_request"); } @@ -2996,7 +3000,7 @@ class ValveCommandRequest final : public CommandProtoMessage { #ifdef USE_DATETIME_DATETIME class ListEntitiesDateTimeResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 112; + static constexpr uint16_t MESSAGE_TYPE = 112; static constexpr uint8_t ESTIMATED_SIZE = 40; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_date_time_response"); } @@ -3011,7 +3015,7 @@ class ListEntitiesDateTimeResponse final : public InfoResponseProtoMessage { }; class DateTimeStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 113; + static constexpr uint16_t MESSAGE_TYPE = 113; static constexpr uint8_t ESTIMATED_SIZE = 16; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("date_time_state_response"); } @@ -3028,7 +3032,7 @@ class DateTimeStateResponse final : public StateResponseProtoMessage { }; class DateTimeCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 114; + static constexpr uint16_t MESSAGE_TYPE = 114; static constexpr uint8_t ESTIMATED_SIZE = 14; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("date_time_command_request"); } @@ -3046,7 +3050,7 @@ class DateTimeCommandRequest final : public CommandProtoMessage { #ifdef USE_UPDATE class ListEntitiesUpdateResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 116; + static constexpr uint16_t MESSAGE_TYPE = 116; static constexpr uint8_t ESTIMATED_SIZE = 49; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_update_response"); } @@ -3062,7 +3066,7 @@ class ListEntitiesUpdateResponse final : public InfoResponseProtoMessage { }; class UpdateStateResponse final : public StateResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 117; + static constexpr uint16_t MESSAGE_TYPE = 117; static constexpr uint8_t ESTIMATED_SIZE = 65; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("update_state_response"); } @@ -3086,7 +3090,7 @@ class UpdateStateResponse final : public StateResponseProtoMessage { }; class UpdateCommandRequest final : public CommandProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 118; + static constexpr uint16_t MESSAGE_TYPE = 118; static constexpr uint8_t ESTIMATED_SIZE = 11; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("update_command_request"); } @@ -3104,7 +3108,7 @@ class UpdateCommandRequest final : public CommandProtoMessage { #ifdef USE_ZWAVE_PROXY class ZWaveProxyFrame final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 128; + static constexpr uint16_t MESSAGE_TYPE = 128; static constexpr uint8_t ESTIMATED_SIZE = 19; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("z_wave_proxy_frame"); } @@ -3122,7 +3126,7 @@ class ZWaveProxyFrame final : public ProtoDecodableMessage { }; class ZWaveProxyRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 129; + static constexpr uint16_t MESSAGE_TYPE = 129; static constexpr uint8_t ESTIMATED_SIZE = 21; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("z_wave_proxy_request"); } @@ -3142,7 +3146,7 @@ class ZWaveProxyRequest final : public ProtoDecodableMessage { }; class ZWaveProxyRequestResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 151; + static constexpr uint16_t MESSAGE_TYPE = 151; static constexpr uint8_t ESTIMATED_SIZE = 4; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("z_wave_proxy_request_response"); } @@ -3161,7 +3165,7 @@ class ZWaveProxyRequestResponse final : public ProtoMessage { #ifdef USE_INFRARED class ListEntitiesInfraredResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 135; + static constexpr uint16_t MESSAGE_TYPE = 135; static constexpr uint8_t ESTIMATED_SIZE = 48; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_infrared_response"); } @@ -3180,7 +3184,7 @@ class ListEntitiesInfraredResponse final : public InfoResponseProtoMessage { #if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) class InfraredRFTransmitRawTimingsRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 136; + static constexpr uint16_t MESSAGE_TYPE = 136; 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"); } @@ -3206,7 +3210,7 @@ class InfraredRFTransmitRawTimingsRequest final : public ProtoDecodableMessage { }; class InfraredRFReceiveEvent final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 137; + static constexpr uint16_t MESSAGE_TYPE = 137; static constexpr uint8_t ESTIMATED_SIZE = 17; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("infrared_rf_receive_event"); } @@ -3228,7 +3232,7 @@ class InfraredRFReceiveEvent final : public ProtoMessage { #ifdef USE_RADIO_FREQUENCY class ListEntitiesRadioFrequencyResponse final : public InfoResponseProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 148; + static constexpr uint16_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"); } @@ -3249,7 +3253,7 @@ class ListEntitiesRadioFrequencyResponse final : public InfoResponseProtoMessage #ifdef USE_SERIAL_PROXY class SerialProxyConfigureRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 138; + static constexpr uint16_t MESSAGE_TYPE = 138; static constexpr uint8_t ESTIMATED_SIZE = 20; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("serial_proxy_configure_request"); } @@ -3269,7 +3273,7 @@ class SerialProxyConfigureRequest final : public ProtoDecodableMessage { }; class SerialProxyDataReceived final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 139; + static constexpr uint16_t MESSAGE_TYPE = 139; static constexpr uint8_t ESTIMATED_SIZE = 23; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("serial_proxy_data_received"); } @@ -3291,7 +3295,7 @@ class SerialProxyDataReceived final : public ProtoMessage { }; class SerialProxyWriteRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 140; + static constexpr uint16_t MESSAGE_TYPE = 140; static constexpr uint8_t ESTIMATED_SIZE = 23; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("serial_proxy_write_request"); } @@ -3309,7 +3313,7 @@ class SerialProxyWriteRequest final : public ProtoDecodableMessage { }; class SerialProxySetModemPinsRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 141; + static constexpr uint16_t MESSAGE_TYPE = 141; static constexpr uint8_t ESTIMATED_SIZE = 8; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("serial_proxy_set_modem_pins_request"); } @@ -3325,7 +3329,7 @@ class SerialProxySetModemPinsRequest final : public ProtoDecodableMessage { }; class SerialProxyGetModemPinsRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 142; + static constexpr uint16_t MESSAGE_TYPE = 142; static constexpr uint8_t ESTIMATED_SIZE = 4; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("serial_proxy_get_modem_pins_request"); } @@ -3340,7 +3344,7 @@ class SerialProxyGetModemPinsRequest final : public ProtoDecodableMessage { }; class SerialProxyGetModemPinsResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 143; + static constexpr uint16_t MESSAGE_TYPE = 143; static constexpr uint8_t ESTIMATED_SIZE = 10; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("serial_proxy_get_modem_pins_response"); } @@ -3358,7 +3362,7 @@ class SerialProxyGetModemPinsResponse final : public ProtoMessage { }; class SerialProxyRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 144; + static constexpr uint16_t MESSAGE_TYPE = 144; static constexpr uint8_t ESTIMATED_SIZE = 6; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("serial_proxy_request"); } @@ -3374,7 +3378,7 @@ class SerialProxyRequest final : public ProtoDecodableMessage { }; class SerialProxyRequestResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 147; + static constexpr uint16_t MESSAGE_TYPE = 147; static constexpr uint8_t ESTIMATED_SIZE = 17; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("serial_proxy_request_response"); } @@ -3395,7 +3399,7 @@ class SerialProxyRequestResponse final : public ProtoMessage { #ifdef USE_BLUETOOTH_PROXY_CONNECTIONS class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 145; + static constexpr uint16_t MESSAGE_TYPE = 145; static constexpr uint8_t ESTIMATED_SIZE = 20; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_set_connection_params_request"); } @@ -3414,7 +3418,7 @@ class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage { }; class BluetoothSetConnectionParamsResponse final : public ProtoMessage { public: - static constexpr uint8_t MESSAGE_TYPE = 146; + static constexpr uint16_t MESSAGE_TYPE = 146; static constexpr uint8_t ESTIMATED_SIZE = 8; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("bluetooth_set_connection_params_response"); } diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index f058f6af22..a226e080e8 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -684,11 +684,6 @@ class ProtoSize { return value < VARINT_THRESHOLD_1_BYTE ? 1 : (value < VARINT_THRESHOLD_2_BYTE ? 2 : 3); } - // Varint encoded length for an 8-bit value (1 or 2 bytes). - static constexpr inline uint8_t ESPHOME_ALWAYS_INLINE varint8(uint8_t value) { - return value < VARINT_THRESHOLD_1_BYTE ? 1 : 2; - } - /** * @brief Calculates the size in bytes needed to encode a uint32_t value as a varint * diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index dc3dd4b868..ca1c5736c8 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -475,6 +475,19 @@ TYPE_INFO: dict[int, TypeInfo] = {} # TYPE_DOUBLE = 1, TYPE_FIXED64 = 6, TYPE_SFIXED64 = 16, TYPE_SINT64 = 18 UNSUPPORTED_TYPES = {1: "double", 6: "fixed64", 16: "sfixed64", 18: "sint64"} +# The plaintext frame header budgets 2 varint bytes for the message type +# (APIPlaintextFrameHelper::HEADER_PADDING), which caps message IDs at 16383. +MAX_MESSAGE_ID = 16383 + + +def validate_message_id(message_id: int, message_name: str) -> None: + """Reject message IDs whose plaintext type varint would not fit in 2 bytes.""" + if message_id > MAX_MESSAGE_ID: + raise ValueError( + f"Message ID {message_id} for {message_name} exceeds the plaintext " + f"2-byte type varint maximum ({MAX_MESSAGE_ID})" + ) + def validate_field_type(field_type: int, field_name: str = "") -> None: """Validate that the field type is supported by ESPHome API. @@ -2549,14 +2562,10 @@ def build_message_type( # Add MESSAGE_TYPE method if this is a service message if message_id is not None: - # Validate that message_id fits in uint8_t - if message_id > 255: - raise ValueError( - f"Message ID {message_id} for {desc.name} exceeds uint8_t maximum (255)" - ) + validate_message_id(message_id, desc.name) # Add static constexpr for message type - public_content.append(f"static constexpr uint8_t MESSAGE_TYPE = {message_id};") + public_content.append(f"static constexpr uint16_t MESSAGE_TYPE = {message_id};") # Add estimated size constant estimated_size = calculate_message_estimated_size(desc) @@ -3212,8 +3221,12 @@ def main() -> None: #include "api_pb2_includes.h" """ - content += """ -namespace esphome::api { + content += f""" +namespace esphome::api {{ + +// Upper bound on message IDs, enforced by the code generator: the plaintext +// frame header budgets 2 varint bytes for the type (HEADER_PADDING). +static constexpr uint16_t MAX_MESSAGE_TYPE = {MAX_MESSAGE_ID}; """ diff --git a/tests/unit_tests/components/api/test_api_protobuf_generator.py b/tests/unit_tests/components/api/test_api_protobuf_generator.py index 2a07cbd49c..797125ba8f 100644 --- a/tests/unit_tests/components/api/test_api_protobuf_generator.py +++ b/tests/unit_tests/components/api/test_api_protobuf_generator.py @@ -15,7 +15,12 @@ import pytest sys.path.insert(0, str(Path(__file__).parents[4] / "script" / "api_protobuf")) -from api_protobuf import _make_ifdef_line, get_varint64_ifdef # noqa: E402 +from api_protobuf import ( # noqa: E402 + MAX_MESSAGE_ID, + _make_ifdef_line, + get_varint64_ifdef, + validate_message_id, +) from google.protobuf import descriptor_pb2 # noqa: E402 @@ -91,3 +96,14 @@ def test_make_ifdef_line_conjunction_and_negation() -> None: assert ( _make_ifdef_line("USE_X && !USE_Y") == "#if defined(USE_X) && !defined(USE_Y)" ) + + +def test_message_id_at_maximum_is_accepted() -> None: + # 16383 is the largest ID whose plaintext type varint fits the 2 bytes + # budgeted in HEADER_PADDING. + validate_message_id(MAX_MESSAGE_ID, "MaxMessage") + + +def test_message_id_above_maximum_is_rejected() -> None: + with pytest.raises(ValueError, match="exceeds the plaintext"): + validate_message_id(MAX_MESSAGE_ID + 1, "TooBigMessage") From cf5ec2d27722ea19dbe88ea052b5aa02259c7c3a Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:42:32 -0400 Subject: [PATCH 03/30] [ci] Remove the remaining max-parallel caps (#18762) --- .github/workflows/ci-docker.yml | 2 -- .github/workflows/ci.yml | 1 - 2 files changed, 3 deletions(-) diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index f3f7cb30eb..42be51cdd9 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -182,8 +182,6 @@ jobs: contents: read # actions/checkout to load the test configs strategy: fail-fast: false - # Modest cap so this smoke test leaves room on the shared runner pool. - max-parallel: 8 matrix: # One entry per distinct toolchain. ESP32 variants (c3/c6/s2/s3/p4) # share a toolchain bundle, so esp32 is exercised on the base variant diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9da0937555..a2762faa4d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -946,7 +946,6 @@ jobs: ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf strategy: fail-fast: false - max-parallel: ${{ needs.determine-jobs.outputs.release-pr == 'true' && 32 || 16 }} matrix: batch: ${{ fromJson(needs.determine-jobs.outputs.component-test-batches) }} steps: From 150f75d8f6f88f158f7df80349f943e4607da0fb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Aug 2026 17:29:57 -0500 Subject: [PATCH 04/30] [esp8266] Add linker-script surgery and board build metadata for the native toolchain (#18555) --- esphome/components/esp8266/__init__.py | 40 +++-- esphome/components/esp8266/boards.py | 136 +++++++++++++++- esphome/components/esp8266/build_surgery.py | 123 +++++++++++++++ esphome/components/esp8266/const.py | 5 + .../components/esp8266/test_boards.py | 33 ++++ .../components/esp8266/test_build_surgery.py | 145 ++++++++++++++++++ 6 files changed, 469 insertions(+), 13 deletions(-) create mode 100644 esphome/components/esp8266/build_surgery.py create mode 100644 tests/unit_tests/components/esp8266/test_boards.py create mode 100644 tests/unit_tests/components/esp8266/test_build_surgery.py diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 3dd9750c6f..75483c5293 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -35,7 +35,7 @@ from esphome.platformio.toolchain import copy_ccache_script from esphome.storage_json import StorageJSON from esphome.types import ConfigType -from .boards import BOARDS, ESP8266_LD_SCRIPTS +from .boards import BOARDS, ESP8266_LD_SCRIPTS, board_ld_script from .const import ( CONF_EARLY_PIN_INIT, CONF_ENABLE_SERIAL, @@ -44,6 +44,7 @@ from .const import ( KEY_BOARD, KEY_ESP8266, KEY_FLASH_SIZE, + KEY_LDSCRIPT, KEY_PIN_INITIAL_STATES, KEY_SERIAL1_REQUIRED, KEY_SERIAL_REQUIRED, @@ -276,6 +277,31 @@ def check_rosetta() -> None: ) +def _choose_ld_script(board: str, ver: cv.Version) -> str | None: + """The flash ld to pin for this board and core, or None for cores + without ld-script support.""" + board_data = BOARDS[board] + ld_scripts = ESP8266_LD_SCRIPTS[board_data[KEY_FLASH_SIZE]] + if ver <= cv.Version(2, 3, 0): + # No ld script support + return None + if ver <= cv.Version(2, 4, 2): + # Old ld script path; the modern per-board override names do not + # exist in this core's SDK, so the override cannot be honored. + # Substituting the size default would move _FS_end and the + # preferences sector, wiping flash-backed state on flash. + if KEY_LDSCRIPT in board_data: + raise EsphomeError( + f"Board {board} requires its {board_data[KEY_LDSCRIPT]} " + f"flash layout, which Arduino core {ver} cannot honor; " + "use a core newer than 2.4.2" + ) + return ld_scripts[0] + # A per-board override preserves a layout the board shipped with + # (see d1_wroom_02 in boards.py) + return board_ld_script(board_data) + + @coroutine_with_priority(CoroPriority.PLATFORM) async def to_code(config: ConfigType) -> None: cg.add(esp8266_ns.setup_preferences()) @@ -397,17 +423,7 @@ async def to_code(config: ConfigType) -> None: ) if config[CONF_BOARD] in BOARDS: - flash_size = BOARDS[config[CONF_BOARD]][KEY_FLASH_SIZE] - ld_scripts = ESP8266_LD_SCRIPTS[flash_size] - - if ver <= cv.Version(2, 3, 0): - # No ld script support - ld_script = None - elif ver <= cv.Version(2, 4, 2): - # Old ld script path - ld_script = ld_scripts[0] - else: - ld_script = ld_scripts[1] + ld_script = _choose_ld_script(config[CONF_BOARD], ver) if ld_script is not None: cg.add_platformio_option("board_build.ldscript", ld_script) diff --git a/esphome/components/esp8266/boards.py b/esphome/components/esp8266/boards.py index 02bfa9e662..268c6b50aa 100644 --- a/esphome/components/esp8266/boards.py +++ b/esphome/components/esp8266/boards.py @@ -1,3 +1,5 @@ +from .const import KEY_FLASH_SIZE, KEY_LDSCRIPT + FLASH_SIZE_1_MB = 2**20 FLASH_SIZE_512_KB = FLASH_SIZE_1_MB // 2 FLASH_SIZE_2_MB = 2 * FLASH_SIZE_1_MB @@ -164,7 +166,8 @@ ESP8266_BOARD_PINS = { } """ -BOARDS generate with: +BOARDS generate with (preserve per-board KEY_LDSCRIPT overrides such as +d1_wroom_02; the recipe emits only name/flash_size): git clone https://github.com/platformio/platform-espressif8266 for x in platform-espressif8266/boards/*.json; do @@ -182,6 +185,19 @@ for x in platform-espressif8266/boards/*.json; do done | sort """ + +def board_ld_script(board_data: dict) -> str: + """The modern (core > 2.4.2) flash linker script for a board: its + shipped-layout override, else the size default (the no-FS layout). + + Single source of truth for the PlatformIO pinning in __init__ and the + native generator's fallback, so the per-board rule cannot drift. + """ + return board_data.get( + KEY_LDSCRIPT, ESP8266_LD_SCRIPTS[board_data[KEY_FLASH_SIZE]][1] + ) + + BOARDS = { "agruminolemon": { "name": "Lifely Agrumino Lemon v4", @@ -199,6 +215,15 @@ BOARDS = { "name": "WeMos D1 mini Pro", "flash_size": FLASH_SIZE_16_MB, }, + "d1_wroom_02": { + "name": "WeMos D1 ESP-WROOM-02", + "flash_size": FLASH_SIZE_2_MB, + # This board joined BOARDS after shipping with the manifest default + # (64 KB filesystem region); the flash-size default (2m.ld) would + # move _FS_end and with it the preferences sector, wiping existing + # devices' flash-backed state on update. + KEY_LDSCRIPT: "eagle.flash.2m64.ld", + }, "d1": { "name": "WEMOS D1 R1", "flash_size": FLASH_SIZE_4_MB, @@ -360,3 +385,112 @@ BOARDS = { "flash_size": FLASH_SIZE_4_MB, }, } + + +# Per-board variant dir + identity defines from platform-espressif8266 4.x +# build.extra_flags; the shared -DESP8266/-DARDUINO_ARCH_ESP8266 are added +# by the generator. +# +# Regenerate ESP8266_BOARD_BUILD with (v4.2.1 is the platform version the +# native toolchain mirrors; regenerate against the tag when bumping it): +# +# git clone -b v4.2.1 https://github.com/platformio/platform-espressif8266 +# python3 - <<'EOF' +# import json, glob, os +# for f in sorted(glob.glob("platform-espressif8266/boards/*.json")): +# b = json.load(open(f))["build"] +# extra = b["extra_flags"] +# extra = extra.split() if isinstance(extra, str) else extra +# defines = [ +# e[2:] for e in extra if e not in ("-DESP8266", "-DARDUINO_ARCH_ESP8266") +# ] +# entries = ", ".join(f'"{d}"' for d in defines) + ("," if len(defines) == 1 else "") +# board = os.path.splitext(os.path.basename(f))[0] +# print(f' "{board}": {{"variant": "{b["variant"]}", "defines": ({entries})}},') +# EOF +ESP8266_BOARD_BUILD = { + "agruminolemon": { + "variant": "agruminolemonv4", + "defines": ("ARDUINO_ESP8266_AGRUMINO_LEMON_V4",), + }, + "d1": {"variant": "d1", "defines": ("ARDUINO_ESP8266_WEMOS_D1R1",)}, + "d1_mini": {"variant": "d1_mini", "defines": ("ARDUINO_ESP8266_WEMOS_D1MINI",)}, + "d1_mini_lite": { + "variant": "d1_mini", + "defines": ("ARDUINO_ESP8266_WEMOS_D1MINILITE",), + }, + "d1_mini_pro": { + "variant": "d1_mini", + "defines": ("ARDUINO_ESP8266_WEMOS_D1MINIPRO",), + }, + "d1_wroom_02": { + "variant": "d1_mini", + "defines": ("ARDUINO_ESP8266_WEMOS_D1WROOM02",), + }, + "eduinowifi": { + "variant": "eduinowifi", + "defines": ("ARDUINO_ESP8266_SCHIRMILABS_EDUINO_WIFI",), + }, + "esp01": {"variant": "generic", "defines": ("ARDUINO_ESP8266_ESP01",)}, + "esp01_1m": {"variant": "generic", "defines": ("ARDUINO_ESP8266_ESP01",)}, + "esp07": {"variant": "generic", "defines": ("ARDUINO_ESP8266_ESP07",)}, + "esp07s": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_ESP07",)}, + "esp12e": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_ESP12",)}, + "esp210": {"variant": "generic", "defines": ("ARDUINO_ESP8266_ESP210",)}, + "esp8285": {"variant": "esp8285", "defines": ("ARDUINO_ESP8266_ESP01",)}, + "esp_wroom_02": { + "variant": "nodemcu", + "defines": ("ARDUINO_ESP8266_ESP_WROOM_02",), + }, + "espduino": {"variant": "ESPDuino", "defines": ("ARDUINO_ESP8266_ESP13",)}, + "espectro": {"variant": "espectro", "defines": ("ARDUINO_ESP8266_ESPECTRO_CORE",)}, + "espino": {"variant": "espino", "defines": ("ARDUINO_ESP8266_ESP12",)}, + "espinotee": {"variant": "espinotee", "defines": ("ARDUINO_ESP8266_ESP13",)}, + "espmxdevkit": { + "variant": "esp8285", + "defines": ("ARDUINO_ESP8266_ESP01", "LED_BUILTIN=16"), + }, + "espresso_lite_v1": { + "variant": "espresso_lite_v1", + "defines": ("ARDUINO_ESP8266_ESPRESSO_LITE_V1",), + }, + "espresso_lite_v2": { + "variant": "espresso_lite_v2", + "defines": ("ARDUINO_ESP8266_ESPRESSO_LITE_V2",), + }, + "gen4iod": {"variant": "generic", "defines": ("ARDUINO_GEN4_IOD",)}, + "heltec_wifi_kit_8": { + "variant": "wifi_kit_8", + "defines": ("ARDUINO_wifi_kit_8",), + }, + "huzzah": {"variant": "adafruit", "defines": ("ARDUINO_ESP8266_ADAFRUIT_HUZZAH",)}, + "inventone": {"variant": "inventone", "defines": ("ARDUINO_ESP8266_INVENT_ONE",)}, + "modwifi": {"variant": "generic", "defines": ("ARDUINO_MOD_WIFI_ESP8266",)}, + "nodemcu": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_NODEMCU",)}, + "nodemcuv2": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_NODEMCU_ESP12E",)}, + "oak": {"variant": "oak", "defines": ("ARDUINO_ESP8266_OAK",)}, + "phoenix_v1": { + "variant": "phoenix_v1", + "defines": ("ARDUINO_ESP8266_PHOENIX_V1",), + }, + "phoenix_v2": { + "variant": "phoenix_v2", + "defines": ("ARDUINO_ESP8266_PHOENIX_V2",), + }, + "sonoff_basic": {"variant": "itead", "defines": ("ARDUINO_ESP8266_SONOFF_BASIC",)}, + "sonoff_s20": {"variant": "itead", "defines": ("ARDUINO_ESP8266_SONOFF_S20",)}, + "sonoff_sv": {"variant": "itead", "defines": ("ARDUINO_ESP8266_SONOFF_SV",)}, + "sonoff_th": {"variant": "itead", "defines": ("ARDUINO_ESP8266_SONOFF_TH",)}, + "sparkfunBlynk": {"variant": "thing", "defines": ("ARDUINO_ESP8266_THING",)}, + "thing": {"variant": "thing", "defines": ("ARDUINO_ESP8266_THING",)}, + "thingdev": {"variant": "thing", "defines": ("ARDUINO_ESP8266_THING_DEV",)}, + "wifi_slot": {"variant": "wifi_slot", "defines": ("ARDUINO_AMPERKA_WIFI_SLOT",)}, + "wifiduino": {"variant": "wifiduino", "defines": ("ARDUINO_WIFIDUINO_ESP8266",)}, + "wifinfo": {"variant": "wifinfo", "defines": ("ARDUINO_WIFINFO",)}, + "wio_link": {"variant": "wiolink", "defines": ("ARDUINO_ESP8266_WIO_LINK",)}, + "wio_node": {"variant": "nodemcu", "defines": ("ARDUINO_ESP8266_ESP_WROOM_02",)}, + "xinabox_cw01": { + "variant": "xinabox", + "defines": ("ARDUINO_ESP8266_XINABOX_CW01",), + }, +} diff --git a/esphome/components/esp8266/build_surgery.py b/esphome/components/esp8266/build_surgery.py new file mode 100644 index 0000000000..eb6ed1b91b --- /dev/null +++ b/esphome/components/esp8266/build_surgery.py @@ -0,0 +1,123 @@ +"""Linker-script surgery shared with the native (PlatformIO-free) toolchain. + +These mirror the PlatformIO extra scripts in this directory +(``relocate_ratetable.py.script`` and ``testing_mode.py.script``), which run +inside SCons and must stay self-contained. The native build generator applies +the same patches to the linker scripts it generates, so the logic lives here +as plain functions. Keep both in sync when changing either. +``segment_length`` is native-toolchain-only and has no script twin. +""" + +from __future__ import annotations + +from collections.abc import Collection +import hashlib +import re + +# Move the NONOS SDK wifi rate tables from flash to DRAM; see +# relocate_ratetable.py.script for the full background (NONOS SDK issue 320). +RATETABLE_RULE = "*libnet80211.a:ieee80211_phy.o(.irom.text .irom.text.*)" +_RATETABLE_COMMENT = ( + "/* ESPHome: wifi rate tables must live in DRAM, see NONOS SDK issue 320 */" +) +# Match the whole line: "_data_start" is also a substring of the +# "_dport0_data_start" line in the earlier .dport0.data section +_RATETABLE_ANCHOR = re.compile(r"^\s*_data_start = ABSOLUTE\(\.\);", re.MULTILINE) + +# Memory sizes for testing mode (allow larger builds for CI component grouping) +TESTING_IRAM_SIZE = "0x200000" # 2MB +TESTING_DRAM_SIZE = "0x200000" # 2MB +TESTING_FLASH_SIZE = "0x2000000" # 32MB + + +def relocate_ratetable(content: str) -> str: + """Insert the rate-table DRAM rule into a generated common linker script.""" + if RATETABLE_RULE in content: + return content + match = _RATETABLE_ANCHOR.search(content) + if match is None: + raise RuntimeError( + "'_data_start' anchor not found in the generated linker script; " + "cannot apply wifi rate table DRAM relocation " + "(has the Arduino core linker script changed?)" + ) + insert_pos = match.end() + return ( + content[:insert_pos] + + f"\n {_RATETABLE_COMMENT}" + + f"\n {RATETABLE_RULE}" + + content[insert_pos:] + ) + + +_TESTING_SEGMENT_SIZES = { + "iram1_0_seg": TESTING_IRAM_SIZE, + "dram0_0_seg": TESTING_DRAM_SIZE, + "irom0_0_seg": TESTING_FLASH_SIZE, +} + + +def _segment_line_re(segment_name: str) -> re.Pattern[str]: + """The MEMORY line for one segment: `` : org = 0x..., len = 0x...``. + + Anchored to the start of the line so a name never matches inside a + longer one (``ram0_0_seg`` must not read ``dram0_0_seg``). The size + group stops at the hex digits, leaving any ``ul`` suffix (from the + preprocessed ``MMU_IRAM_SIZE``) in place. + """ + return re.compile( + rf"(^[ \t]*{re.escape(segment_name)}" + r"\s*:\s*org\s*=\s*0x[0-9a-fA-F]+\s*,\s*len\s*=\s*)" + r"(0x[0-9a-fA-F]+)", + re.MULTILINE, + ) + + +def apply_testing_memory_patches(content: str, segments: Collection[str]) -> str: + """Enlarge the named memory segments so grouped CI test builds can link. + + Each caller passes the segments its linker script defines: the + generated common ld carries ``iram1_0_seg``; the flash ld carries + ``dram0_0_seg`` and ``irom0_0_seg``. A segment that fails to match + raises, since a silently kept real memory limit would fail grouped + builds far from the cause. + """ + for segment in _TESTING_SEGMENT_SIZES: + if segment not in segments and _segment_line_re(segment).search(content): + raise RuntimeError( + f"Testing-mode segment {segment} is present in the linker " + "script but was not selected for patching" + ) + for segment in segments: + if segment not in _TESTING_SEGMENT_SIZES: + raise RuntimeError(f"Unknown testing-mode segment {segment!r}") + content, count = _segment_line_re(segment).subn( + rf"\g<1>{_TESTING_SEGMENT_SIZES[segment]}", content + ) + if count == 0: + raise RuntimeError( + f"Testing-mode memory patch failed: segment {segment} " + "not found (has the Arduino core linker script changed?)" + ) + return content + + +def segment_length(content: str, segment_name: str) -> int | None: + """Read a memory segment's length from linker script content. + + Returns None for an absent segment OR an unparsable line; callers must + treat None as "no usable budget" and warn (as the Flash summary does), + never as "no limit". + """ + match = _segment_line_re(segment_name).search(content) + return int(match.group(2), 16) if match else None + + +def surgery_fingerprint() -> str: + """Hash of this module's source; linker-script caches include it so an + edit here invalidates them.""" + import inspect + import sys + + source = inspect.getsource(sys.modules[__name__]) + return hashlib.sha256(source.encode()).hexdigest() diff --git a/esphome/components/esp8266/const.py b/esphome/components/esp8266/const.py index 3e89ab989f..50f103ed2d 100644 --- a/esphome/components/esp8266/const.py +++ b/esphome/components/esp8266/const.py @@ -15,6 +15,11 @@ CONF_ENABLE_SERIAL1 = "enable_serial1" KEY_WAVEFORM_REQUIRED = "waveform_required" KEY_SERIAL_REQUIRED = "serial_required" KEY_SERIAL1_REQUIRED = "serial1_required" +# Set for the native (non-PlatformIO) toolchain's build generator +KEY_FLASH_MODE = "flash_mode" +KEY_SCANF_FLOAT = "scanf_float" +# Per-board flash-layout override consumed by board_ld_script() +KEY_LDSCRIPT = "ldscript" # esp8266 namespace is already defined by arduino, manually prefix esphome esp8266_ns = cg.global_ns.namespace("esphome").namespace("esp8266") diff --git a/tests/unit_tests/components/esp8266/test_boards.py b/tests/unit_tests/components/esp8266/test_boards.py new file mode 100644 index 0000000000..df0e536d42 --- /dev/null +++ b/tests/unit_tests/components/esp8266/test_boards.py @@ -0,0 +1,33 @@ +"""Tests for the per-board linker-script rule.""" + +import pytest + +from esphome.components.esp8266 import _choose_ld_script +from esphome.components.esp8266.boards import BOARDS, board_ld_script +import esphome.config_validation as cv +from esphome.core import EsphomeError + + +def test_d1_wroom_02_keeps_its_shipped_layout() -> None: + """The override must survive a BOARDS regeneration or key typo: the + 2m.ld default moves _FS_end and the preferences sector on deployed + devices.""" + assert board_ld_script(BOARDS["d1_wroom_02"]) == "eagle.flash.2m64.ld" + + +def test_default_boards_use_the_flash_size_layout() -> None: + assert board_ld_script(BOARDS["d1_mini"]) == "eagle.flash.4m.ld" + assert board_ld_script(BOARDS["esp01_1m"]) == "eagle.flash.1m.ld" + + +def test_choose_ld_script_paths() -> None: + """Old cores get the size default, overriding boards hard-error there + (a substituted layout would wipe flash-backed state), modern cores + honor the override.""" + assert _choose_ld_script("nodemcuv2", cv.Version(2, 3, 0)) is None + assert _choose_ld_script("nodemcuv2", cv.Version(2, 4, 2)) == "eagle.flash.4m.ld" + assert _choose_ld_script("d1_wroom_02", cv.Version(2, 7, 4)) == ( + "eagle.flash.2m64.ld" + ) + with pytest.raises(EsphomeError, match="cannot honor"): + _choose_ld_script("d1_wroom_02", cv.Version(2, 4, 2)) diff --git a/tests/unit_tests/components/esp8266/test_build_surgery.py b/tests/unit_tests/components/esp8266/test_build_surgery.py new file mode 100644 index 0000000000..411a35eb96 --- /dev/null +++ b/tests/unit_tests/components/esp8266/test_build_surgery.py @@ -0,0 +1,145 @@ +"""Tests for the linker-script surgery shared with the native toolchain.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +import sys + +import pytest + +from esphome.components.esp8266 import build_surgery +from esphome.components.esp8266.boards import BOARDS, ESP8266_BOARD_BUILD +from esphome.components.esp8266.build_surgery import ( + RATETABLE_RULE, + apply_testing_memory_patches, + relocate_ratetable, + segment_length, +) + +_COMMON_LD_SNIPPET = """\ + .dport0.data : ALIGN(4) + { + _dport0_data_start = ABSOLUTE(.); + } >dport0_0_seg :dport0_0_phdr + .data : ALIGN(4) + { + _data_start = ABSOLUTE(.); + *(.data) + } >dram0_0_seg :dram0_0_phdr +""" + +# Shaped like the real SDK flash ld scripts: no iram1_0_seg (that lives in +# the generated common ld only) +_FLASH_LD_SNIPPET = """\ +MEMORY +{ + dport0_0_seg : org = 0x3FF00000, len = 0x10 + dram0_0_seg : org = 0x3FFE8000, len = 0x14000 + irom0_0_seg : org = 0x40201010, len = 0xfeff0 +} +""" + +# Shaped like the preprocessed common ld: MMU_IRAM_SIZE expands with a ul +# suffix the patcher must leave in place +_COMMON_LD_MEMORY_SNIPPET = """\ +MEMORY +{ + iram1_0_seg : org = 0x40100000, len = 0x8000ul +} +""" + + +def test_relocate_ratetable_inserts_after_data_start() -> None: + patched = relocate_ratetable(_COMMON_LD_SNIPPET) + assert RATETABLE_RULE in patched + # Inserted after the .data section's anchor, not the .dport0.data one + # (whose closing brace bounds the decoy block) + assert RATETABLE_RULE not in patched[: patched.index("} >dport0_0_seg")] + assert patched.index(RATETABLE_RULE) < patched.index("*(.data)") + # Idempotent on an already-patched script + assert relocate_ratetable(patched) == patched + + +def test_relocate_ratetable_requires_anchor() -> None: + with pytest.raises(RuntimeError, match="_data_start"): + relocate_ratetable("SECTIONS { }") + + +def test_testing_memory_patches_enlarge_segments() -> None: + patched = apply_testing_memory_patches( + _FLASH_LD_SNIPPET, ("dram0_0_seg", "irom0_0_seg") + ) + assert segment_length(patched, "dram0_0_seg") == 0x200000 + assert segment_length(patched, "irom0_0_seg") == 0x2000000 + # Untouched segments keep their sizes + assert segment_length(patched, "dport0_0_seg") == 0x10 + + +def test_testing_memory_patches_keep_ul_suffix() -> None: + """The common ld's preprocessed sizes carry a ul suffix; the patch must + replace only the hex digits, as testing_mode.py.script does.""" + patched = apply_testing_memory_patches(_COMMON_LD_MEMORY_SNIPPET, ("iram1_0_seg",)) + assert "len = 0x200000ul" in patched + assert segment_length(patched, "iram1_0_seg") == 0x200000 + + +def test_segment_length_requires_whole_name() -> None: + """A name must match its own line, never inside a longer segment name.""" + assert segment_length(_FLASH_LD_SNIPPET, "ram0_0_seg") is None + + +def test_testing_memory_patches_unknown_segment_raises() -> None: + with pytest.raises(RuntimeError, match="Unknown testing-mode segment"): + apply_testing_memory_patches("MEMORY { }", ("bogus_seg",)) + + +def test_segment_length() -> None: + assert segment_length(_FLASH_LD_SNIPPET, "irom0_0_seg") == 0xFEFF0 + assert segment_length(_FLASH_LD_SNIPPET, "missing_seg") is None + + +def test_testing_memory_patches_missing_segment_raises() -> None: + """A named segment the patch could not find raises instead of silently + keeping the real memory limits.""" + with pytest.raises(RuntimeError, match="dram0_0_seg"): + apply_testing_memory_patches("MEMORY { }", ("dram0_0_seg",)) + + +def test_board_build_covers_every_board() -> None: + """Every supported board has native build metadata (the table may carry + extras that BOARDS does not expose).""" + assert set(BOARDS) <= set(ESP8266_BOARD_BUILD) + + +def test_surgery_fingerprint_is_stable_and_sensitive(tmp_path) -> None: + """The properties the linker-script cache depends on: the fingerprint is + stable across calls and changes when the module's source changes.""" + + first = build_surgery.surgery_fingerprint() + assert first == build_surgery.surgery_fingerprint() + assert len(first) == 64 + int(first, 16) # sha256 hex digest + + # A modified copy of the module must fingerprint differently + copy = tmp_path / "build_surgery_variant.py" + copy.write_text( + Path(build_surgery.__file__).read_text(encoding="utf-8") + + "\nEXTRA_BEHAVIORAL_INPUT = 1\n", + encoding="utf-8", + ) + spec = importlib.util.spec_from_file_location("build_surgery_variant", copy) + variant = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = variant + try: + spec.loader.exec_module(variant) + assert variant.surgery_fingerprint() != first + finally: + del sys.modules[spec.name] + + +def test_testing_memory_patches_present_but_unselected_raises() -> None: + """A known segment left off the caller's list must fail, not silently + keep its real memory limit.""" + with pytest.raises(RuntimeError, match="not selected"): + apply_testing_memory_patches(_FLASH_LD_SNIPPET, ("dram0_0_seg",)) From c272c4c1a64d08547e510c3e9e6b90ccaadfda0d Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:44:42 +1000 Subject: [PATCH 05/30] [lvgl] Add table widget (#18422) Co-authored-by: Claude Sonnet 5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/lvgl/lvgl_esphome.cpp | 46 +++ esphome/components/lvgl/lvgl_esphome.h | 25 ++ esphome/components/lvgl/widgets/table.py | 280 ++++++++++++++++++ tests/components/lvgl/lvgl-package.yaml | 32 ++ tests/unit_tests/components/lvgl/__init__.py | 0 .../components/lvgl/test_table_codegen.py | 206 +++++++++++++ .../components/lvgl/test_table_config.py | 142 +++++++++ 7 files changed, 731 insertions(+) create mode 100644 esphome/components/lvgl/widgets/table.py create mode 100644 tests/unit_tests/components/lvgl/__init__.py create mode 100644 tests/unit_tests/components/lvgl/test_table_codegen.py create mode 100644 tests/unit_tests/components/lvgl/test_table_config.py diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 22fccdd92a..684f472ebd 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -525,6 +525,52 @@ void IndicatorLine::update_length_() { } #endif +#ifdef USE_LVGL_TABLE +uint32_t lv_table_get_selected_row(lv_obj_t *obj) { + uint32_t row; + uint32_t column; + lv_table_get_selected_cell(obj, &row, &column); + return row; +} + +uint32_t lv_table_get_selected_column(lv_obj_t *obj) { + uint32_t row; + uint32_t column; + lv_table_get_selected_cell(obj, &row, &column); + return column; +} + +void LvTableType::set_obj(lv_obj_t *lv_obj) { + LvCompound::set_obj(lv_obj); + lv_obj_add_event_cb( + lv_obj, + [](lv_event_t *e) { + auto *table = static_cast(lv_event_get_user_data(e)); + table->update_column_widths_(); + }, + LV_EVENT_SIZE_CHANGED, this); +} + +void LvTableType::add_column_width_pct(uint32_t col, uint8_t pct) { + for (auto &i : this->column_pct_) { + if (i.col == col) { + i.pct = pct; + this->update_column_widths_(); + return; + } + } + this->column_pct_.push_back({col, pct}); + this->update_column_widths_(); +} + +void LvTableType::update_column_widths_() { + auto content_width = lv_obj_get_content_width(this->obj); + for (const auto &col : this->column_pct_) { + lv_table_set_column_width(this->obj, col.col, content_width * col.pct / 100); + } +} +#endif // USE_LVGL_TABLE + #ifdef USE_LVGL_KEY_LISTENER LVEncoderListener::LVEncoderListener(lv_indev_type_t type, uint16_t long_press_time, uint16_t long_press_repeat_time) { this->drv_ = lv_indev_create(); diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 98b97e26d7..ceba786e43 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -58,6 +58,10 @@ lv_obj_t *lv_container_create(lv_obj_t *parent); void lv_scale_draw_event_cb(lv_event_t *e, int16_t range_start, int16_t range_end, lv_color_t color_start, lv_color_t color_end, int width, bool local); #endif +#ifdef USE_LVGL_TABLE +uint32_t lv_table_get_selected_row(lv_obj_t *obj); +uint32_t lv_table_get_selected_column(lv_obj_t *obj); +#endif #if LV_COLOR_DEPTH == 16 static const display::ColorBitness LV_BITNESS = display::ColorBitness::COLOR_BITNESS_565; #elif LV_COLOR_DEPTH == 32 @@ -511,6 +515,27 @@ class LvLineType : public LvCompound { FixedVector points_{}; }; #endif +#ifdef USE_LVGL_TABLE +// Unlike most size properties, lv_table_set_column_width() only accepts a literal pixel +// count, so percentage column widths must be recomputed by hand whenever the table's own +// content width changes. +class LvTableType : public LvCompound { + public: + void set_obj(lv_obj_t *lv_obj) override; + // count is the number of percentage-width columns, known at code-generation time. + void init_column_pct(size_t count) { this->column_pct_.init(count); } + void add_column_width_pct(uint32_t col, uint8_t pct); + + protected: + void update_column_widths_(); + + struct ColumnPct { + uint32_t col; + uint8_t pct; + }; + FixedVector column_pct_{}; +}; +#endif // USE_LVGL_TABLE #if defined(USE_LVGL_DROPDOWN) || defined(LV_USE_ROLLER) class LvSelectable : public LvCompound { public: diff --git a/esphome/components/lvgl/widgets/table.py b/esphome/components/lvgl/widgets/table.py new file mode 100644 index 0000000000..efae2be2be --- /dev/null +++ b/esphome/components/lvgl/widgets/table.py @@ -0,0 +1,280 @@ +from contextlib import ExitStack + +from esphome import automation +import esphome.codegen as cg +from esphome.components.const import CONF_ROWS +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_ITEMS, CONF_ROW, CONF_TEXT, CONF_WIDTH +from esphome.core import ID +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.schema_extractors import SCHEMA_EXTRACT +from esphome.types import ConfigFragmentType, ConfigType, SafeExpType + +from ..automation import action_to_code +from ..defines import CONF_COLUMN, CONF_MAIN, LValidator, literal +from ..lv_validation import lv_int, lv_text, pixels_or_percent, pixels_validator +from ..lvcode import LocalVariable, lv, lv_add, lv_expr +from ..types import LvCompound, LvType, ObjUpdateAction, lv_coord_t +from . import Widget, WidgetType, get_widgets +from .label import CONF_LABEL + +CONF_TABLE = "table" +CONF_CELLS = "cells" +CONF_COLUMNS = "columns" +CONF_ROW_COUNT = "row_count" +CONF_COLUMN_COUNT = "column_count" +CONF_MERGE_RIGHT = "merge_right" +CONF_TEXT_CROP = "text_crop" +CONF_SELECTED_ROW = "selected_row" +CONF_SELECTED_COLUMN = "selected_column" + +CELL_SCHEMA = cv.Schema( + { + cv.Optional(CONF_TEXT, default=""): lv_text, + # Not templatable: the value selects between two different LVGL calls + # (set/clear cell ctrl), so a runtime lambda can't be mapped to a single call. + cv.Optional(CONF_MERGE_RIGHT): cv.boolean, + cv.Optional(CONF_TEXT_CROP): cv.boolean, + } +) + +# A cell can be given as a bare piece of text, or a dict for more control +TABLE_CELL_SCHEMA = cv.maybe_simple_value(CELL_SCHEMA, key=CONF_TEXT) + +# A row can be given as a bare list of cells, or a dict for future extension +ROW_SCHEMA = cv.maybe_simple_value( + cv.Schema({cv.Required(CONF_CELLS): cv.ensure_list(TABLE_CELL_SCHEMA)}), + key=CONF_CELLS, +) + + +def _column_width_validator(value: ConfigFragmentType) -> int | float | list[str]: + """Like pixels_or_percent, but rejects negative widths, which would + defeat the 100%-total check and wrap around in the generated uint8_t pct.""" + if value == SCHEMA_EXTRACT: + return ["pixels", "..%"] + return cv.Any(pixels_validator, cv.percentage)(value) + + +column_width = LValidator( + _column_width_validator, + lv_coord_t, + retmapper=pixels_or_percent.retmapper, + animatable=True, +) + +COLUMN_SCHEMA = cv.Schema( + { + cv.Optional(CONF_WIDTH): column_width, + } +) + + +def _validate_table(config: ConfigType) -> ConfigType: + rows = config.get(CONF_ROWS) + min_row_count = len(rows) if rows else 0 + min_column_count = max(len(row[CONF_CELLS]) for row in rows) if rows else 0 + row_count = config.get(CONF_ROW_COUNT) + if row_count is not None and row_count < min_row_count: + raise cv.Invalid( + f"{CONF_ROW_COUNT} must be at least {min_row_count} to hold all the given rows", + path=[CONF_ROW_COUNT], + ) + column_count = config.get(CONF_COLUMN_COUNT) + if column_count is not None and column_count < min_column_count: + raise cv.Invalid( + f"{CONF_COLUMN_COUNT} must be at least {min_column_count} to hold all the cells in a row", + path=[CONF_COLUMN_COUNT], + ) + column_count = column_count if column_count is not None else min_column_count + columns = config.get(CONF_COLUMNS) + if columns and column_count and len(columns) > column_count: + raise cv.Invalid( + f"{CONF_COLUMNS} defines {len(columns)} columns, but the table has only {column_count}", + path=[CONF_COLUMNS], + ) + total_pct = sum( + width + for column in columns or () + if isinstance((width := column.get(CONF_WIDTH)), float) + ) + if total_pct > 1.0: + raise cv.Invalid( + f"{CONF_COLUMNS} percentage widths add up to {total_pct * 100:.0f}%, which exceeds 100%", + path=[CONF_COLUMNS], + ) + return config + + +TABLE_SCHEMA = cv.Schema( + { + cv.Optional(CONF_ROWS): cv.ensure_list(ROW_SCHEMA), + cv.Optional(CONF_ROW_COUNT): cv.positive_int, + cv.Optional(CONF_COLUMN_COUNT): cv.positive_int, + cv.Optional(CONF_COLUMNS): cv.ensure_list(COLUMN_SCHEMA), + cv.Optional(CONF_SELECTED_ROW): lv_int, + cv.Optional(CONF_SELECTED_COLUMN): lv_int, + } +).add_extra(_validate_table) + +lv_table_t = LvType( + "LvTableType", + parents=(LvCompound,), + largs=[(cg.uint32, "row"), (cg.uint32, "column")], + lvalue=lambda w: [ + lv_expr.table_get_selected_row(w.obj), + lv_expr.table_get_selected_column(w.obj), + ], + has_on_value=True, +) + + +async def set_cell_ctrl( + w: Widget, row: SafeExpType, column: SafeExpType, cell: ConfigType +) -> None: + for key, ctrl in ( + (CONF_MERGE_RIGHT, "LV_TABLE_CELL_CTRL_MERGE_RIGHT"), + (CONF_TEXT_CROP, "LV_TABLE_CELL_CTRL_TEXT_CROP"), + ): + if key not in cell: + continue + if cell[key]: + lv.table_set_cell_ctrl(w.obj, row, column, literal(ctrl)) + else: + lv.table_clear_cell_ctrl(w.obj, row, column, literal(ctrl)) + + +async def set_selected_cell(w: Widget, config: ConfigType) -> None: + selected_row = config.get(CONF_SELECTED_ROW) + selected_column = config.get(CONF_SELECTED_COLUMN) + if selected_row is None and selected_column is None: + return + # LV_TABLE_CELL_NONE selects the whole column/row when only one index is given + row_value = ( + await lv_int.process(selected_row) + if selected_row is not None + else literal("LV_TABLE_CELL_NONE") + ) + column_value = ( + await lv_int.process(selected_column) + if selected_column is not None + else literal("LV_TABLE_CELL_NONE") + ) + lv.table_set_selected_cell(w.obj, row_value, column_value) + + +TABLE_MODIFY_SCHEMA = cv.Schema( + { + cv.Optional(CONF_SELECTED_ROW): lv_int, + cv.Optional(CONF_SELECTED_COLUMN): lv_int, + } +) + + +class TableType(WidgetType): + def __init__(self): + super().__init__( + CONF_TABLE, + lv_table_t, + (CONF_MAIN, CONF_ITEMS), + TABLE_SCHEMA, + modify_schema=TABLE_MODIFY_SCHEMA, + ) + + def get_uses(self) -> tuple[str]: + return (CONF_LABEL,) + + async def to_code(self, w: Widget, config: dict) -> None: + rows = config.get(CONF_ROWS) + row_count = config.get(CONF_ROW_COUNT) + column_count = config.get(CONF_COLUMN_COUNT) + if rows is not None: + if row_count is None: + row_count = len(rows) + if column_count is None: + column_count = max((len(row[CONF_CELLS]) for row in rows), default=0) + if row_count is not None: + lv.table_set_row_count(w.obj, row_count) + if column_count is not None: + lv.table_set_column_count(w.obj, column_count) + columns = config.get(CONF_COLUMNS, ()) + pct_column_count = sum( + 1 for column in columns if isinstance(column.get(CONF_WIDTH), float) + ) + if pct_column_count: + lv_add(w.var.init_column_pct(pct_column_count)) + for index, column in enumerate(columns): + if (width := column.get(CONF_WIDTH)) is None: + continue + if isinstance(width, float): + # A percentage: column_width validation leaves it as a 0.0-1.0 + # fraction. LVGL's table widget only accepts a literal pixel width, so + # the actual width is recomputed at runtime from the table's own size. + lv_add(w.var.add_column_width_pct(index, round(width * 100))) + else: + lv.table_set_column_width( + w.obj, index, await column_width.process(width) + ) + for row_index, row in enumerate(rows or ()): + for column_index, cell in enumerate(row[CONF_CELLS]): + lv.table_set_cell_value( + w.obj, + row_index, + column_index, + await lv_text.process(cell[CONF_TEXT]), + ) + await set_cell_ctrl(w, row_index, column_index, cell) + await set_selected_cell(w, config) + + +table_spec = TableType() + + +@automation.register_action( + "lvgl.table.cell.update", + ObjUpdateAction, + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(lv_table_t), + cv.Required(CONF_ROW): lv_int, + cv.Required(CONF_COLUMN): lv_int, + cv.Optional(CONF_TEXT): lv_text, + cv.Optional(CONF_MERGE_RIGHT): cv.boolean, + cv.Optional(CONF_TEXT_CROP): cv.boolean, + } + ).add_extra(cv.has_at_least_one_key(CONF_TEXT, CONF_MERGE_RIGHT, CONF_TEXT_CROP)), + synchronous=True, +) +async def table_cell_update_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + widgets = await get_widgets(config) + + async def do_update(w: Widget): + row = await lv_int.process(config[CONF_ROW]) + column = await lv_int.process(config[CONF_COLUMN]) + fields_set = sum( + key in config for key in (CONF_TEXT, CONF_MERGE_RIGHT, CONF_TEXT_CROP) + ) + with ExitStack() as stack: + if fields_set > 1: + # row/column feed more than one generated call below: cache them in + # local variables so a !lambda value is only evaluated once. + row = stack.enter_context( + LocalVariable("row", cg.int_, row, modifier="") + ) + column = stack.enter_context( + LocalVariable("column", cg.int_, column, modifier="") + ) + if CONF_TEXT in config: + lv.table_set_cell_value( + w.obj, row, column, await lv_text.process(config[CONF_TEXT]) + ) + await set_cell_ctrl(w, row, column, config) + + return await action_to_code( + widgets, do_update, action_id, template_arg, args, config + ) diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index c78e910bc8..57be4e9043 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -1181,6 +1181,38 @@ lvgl: - logger.log: format: "bar value %f" args: [x] + - table: + id: table_id + align: top_mid + y: 60 + columns: + - width: 40% + - width: 80 + rows: + - ["Name", "Value"] + - cells: + - text: "Temp" + merge_right: true + - text: "22.5" + text_crop: true + selected_row: 0 + on_value: + then: + - logger.log: + format: "table selected row %u col %u" + args: [row, column] + on_click: + then: + - lvgl.table.cell.update: + id: table_id + row: 1 + column: 1 + text: !lambda return str_sprintf("%.1f", (float) rand() / RAND_MAX * 100); + merge_right: false + - lvgl.table.update: + id: table_id + selected_row: !lambda return (int) ((float) rand() / RAND_MAX * 2); + selected_column: 0 - line: id: lv_line_id align: center diff --git a/tests/unit_tests/components/lvgl/__init__.py b/tests/unit_tests/components/lvgl/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/lvgl/test_table_codegen.py b/tests/unit_tests/components/lvgl/test_table_codegen.py new file mode 100644 index 0000000000..390f67dffc --- /dev/null +++ b/tests/unit_tests/components/lvgl/test_table_codegen.py @@ -0,0 +1,206 @@ +"""Tests for the LVGL table widget's C++ code generation.""" + +from __future__ import annotations + +import pytest + +from esphome.automation import ACTION_REGISTRY +from esphome.components.lvgl.defines import set_widgets_completed +from esphome.components.lvgl.lvcode import LvContext +from esphome.components.lvgl.schemas import container_schema +from esphome.components.lvgl.trigger import generate_triggers +from esphome.components.lvgl.widgets import Widget, widget_to_code +from esphome.components.lvgl.widgets.table import table_spec +from esphome.const import ( + CONF_AUTOMATION_ID, + CONF_ON_VALUE, + CONF_THEN, + CONF_TRIGGER_ID, + CONF_TYPE_ID, +) +from esphome.core import CORE, ID +from esphome.cpp_generator import MockObj, TemplateArguments +from esphome.yaml_util import make_data_base + + +async def _create_table(raw_config: dict) -> Widget: + """Validate `raw_config` as a table widget and generate its creation code.""" + config = container_schema(table_spec)(raw_config) + parent = MockObj("parent_obj") + async with LvContext(): + return await widget_to_code(config, table_spec, parent) + + +def _statements() -> list[str]: + return [str(s) for s in CORE.main_statements] + + +@pytest.mark.asyncio +async def test_create_table_sets_row_and_column_count(setup_core) -> None: + await _create_table( + {"id": "table_counts", "rows": [["Name", "Value"], ["Temp", "22.5"]]} + ) + statements = _statements() + assert any("lv_table_set_row_count(table_counts->obj, 2)" in s for s in statements) + assert any( + "lv_table_set_column_count(table_counts->obj, 2)" in s for s in statements + ) + + +@pytest.mark.asyncio +async def test_create_table_writes_cell_values(setup_core) -> None: + await _create_table({"id": "table_cells", "rows": [["Name", "Value"]]}) + statements = _statements() + assert any( + 'lv_table_set_cell_value(table_cells->obj, 0, 0, "Name")' in s + for s in statements + ) + assert any( + 'lv_table_set_cell_value(table_cells->obj, 0, 1, "Value")' in s + for s in statements + ) + + +@pytest.mark.asyncio +async def test_create_table_sets_cell_control_flags(setup_core) -> None: + await _create_table( + { + "id": "table_ctrl", + "rows": [ + { + "cells": [ + {"text": "wide", "merge_right": True}, + {"text": "cropped", "text_crop": True}, + ] + } + ], + } + ) + statements = _statements() + assert any( + "lv_table_set_cell_ctrl(table_ctrl->obj, 0, 0, LV_TABLE_CELL_CTRL_MERGE_RIGHT)" + in s + for s in statements + ) + assert any( + "lv_table_set_cell_ctrl(table_ctrl->obj, 0, 1, LV_TABLE_CELL_CTRL_TEXT_CROP)" + in s + for s in statements + ) + # text_crop omitted for cell 0: no clear_cell_ctrl() should be emitted. + assert not any( + "table_ctrl->obj, 0, 0, LV_TABLE_CELL_CTRL_TEXT_CROP" in s for s in statements + ) + + +@pytest.mark.asyncio +async def test_pixel_column_width_calls_lvgl_directly(setup_core) -> None: + await _create_table({"id": "table_px", "columns": [{"width": 96}]}) + statements = _statements() + assert any( + "lv_table_set_column_width(table_px->obj, 0, 96)" in s for s in statements + ) + + +@pytest.mark.asyncio +async def test_percent_column_width_uses_the_dynamic_helper(setup_core) -> None: + """Regression test: lv_table_set_column_width() only accepts a literal + pixel count, so a percentage width must not be passed to it directly - + it has to go through the LvTableType helper that recomputes it at + runtime from the table's actual content width. + """ + await _create_table({"id": "table_pct", "columns": [{"width": "40%"}]}) + statements = _statements() + assert any("table_pct->init_column_pct(1)" in s for s in statements) + assert any("table_pct->add_column_width_pct(0, 40)" in s for s in statements) + assert not any( + "lv_table_set_column_width(table_pct->obj, 0" in s for s in statements + ) + + +@pytest.mark.asyncio +async def test_selected_cell_with_both_indices(setup_core) -> None: + await _create_table( + {"id": "table_sel_both", "selected_row": 1, "selected_column": 2} + ) + statements = _statements() + assert any( + "lv_table_set_selected_cell(table_sel_both->obj, 1, 2)" in s for s in statements + ) + + +@pytest.mark.asyncio +async def test_selected_cell_with_only_row_selects_whole_row(setup_core) -> None: + await _create_table({"id": "table_sel_row", "selected_row": 1}) + statements = _statements() + assert any( + "lv_table_set_selected_cell(table_sel_row->obj, 1, LV_TABLE_CELL_NONE)" in s + for s in statements + ) + + +@pytest.mark.asyncio +async def test_selected_cell_omitted_entirely_when_not_configured( + setup_core, +) -> None: + await _create_table({"id": "table_no_selection", "rows": [["a"]]}) + statements = _statements() + assert not any("lv_table_set_selected_cell" in s for s in statements) + + +@pytest.mark.asyncio +async def test_cell_update_action_writes_only_the_given_fields(setup_core) -> None: + await _create_table({"id": "table_update", "rows": [["a", "b"], ["c", "d"]]}) + set_widgets_completed(True) + # Only inspect statements emitted by the action below, not by creation. + before = len(_statements()) + + entry = ACTION_REGISTRY["lvgl.table.cell.update"] + config = entry.schema( + {"id": "table_update", "row": 1, "column": 1, "text": "new value"} + ) + action_id = ID("test_cell_update_action", is_declaration=True, type=entry.type_id) + await entry.coroutine_fun(config, action_id, TemplateArguments(), []) + + statements = _statements()[before:] + assert any( + 'lv_table_set_cell_value(table_update->obj, 1, 1, "new value")' in s + for s in statements + ) + # Neither control flag was specified, so neither call should be emitted. + assert not any("LV_TABLE_CELL_CTRL" in s for s in statements) + + +@pytest.mark.asyncio +async def test_on_value_registers_a_value_changed_event_callback(setup_core) -> None: + config = container_schema(table_spec)( + { + "id": "table_on_value", + "rows": [["a"]], + "on_value": [ + {"lambda": make_data_base("id(table_on_value).get_selected_row();")} + ], + } + ) + # Auto-generated IDs (trigger/automation/action) are normally resolved to + # unique names by esphome's full config pass before code generation; do + # that by hand here since this test only exercises the widget/trigger + # codegen slice in isolation. + automation_conf = config[CONF_ON_VALUE][0] + automation_conf[CONF_TRIGGER_ID].resolve([]) + automation_conf[CONF_AUTOMATION_ID].resolve([]) + automation_conf[CONF_THEN][0][CONF_TYPE_ID].resolve([]) + + parent = MockObj("parent_obj") + async with LvContext(): + await widget_to_code(config, table_spec, parent) + set_widgets_completed(True) + await generate_triggers() + + statements = _statements() + assert any( + "table_on_value->obj" in s + and "add_event_cb" in s + and "LV_EVENT_VALUE_CHANGED" in s + for s in statements + ) diff --git a/tests/unit_tests/components/lvgl/test_table_config.py b/tests/unit_tests/components/lvgl/test_table_config.py new file mode 100644 index 0000000000..047d1781ae --- /dev/null +++ b/tests/unit_tests/components/lvgl/test_table_config.py @@ -0,0 +1,142 @@ +"""Tests for the LVGL table widget's configuration validation.""" + +from __future__ import annotations + +import pytest + +from esphome import config_validation as cv +from esphome.automation import ACTION_REGISTRY +from esphome.components.lvgl.widgets.table import ( + CONF_MERGE_RIGHT, + CONF_TEXT_CROP, + TABLE_SCHEMA, +) + + +def test_minimal_config_is_valid() -> None: + assert TABLE_SCHEMA({}) == {} + + +def test_row_shorthand_expands_to_plain_cells() -> None: + config = TABLE_SCHEMA({"rows": [["Name", "Value"]]}) + [row] = config["rows"] + assert row["cells"] == [{"text": "Name"}, {"text": "Value"}] + + +def test_row_dict_form_with_cell_overrides() -> None: + config = TABLE_SCHEMA( + { + "rows": [ + { + "cells": [ + "Temp", + {"text": "22.5", "text_crop": True, "merge_right": True}, + ] + } + ] + } + ) + [row] = config["rows"] + assert row["cells"][0] == {"text": "Temp"} + assert row["cells"][1] == { + "text": "22.5", + "merge_right": True, + "text_crop": True, + } + + +def test_row_count_defaults_are_not_injected_by_the_schema() -> None: + # Inference of row/column counts from `rows` happens at code generation + # time, not during validation - the schema should leave them unset. + config = TABLE_SCHEMA({"rows": [["a", "b"], ["c"]]}) + assert "row_count" not in config + assert "column_count" not in config + + +def test_explicit_row_and_column_count_are_kept() -> None: + config = TABLE_SCHEMA({"row_count": 5, "column_count": 3}) + assert config["row_count"] == 5 + assert config["column_count"] == 3 + + +def test_row_count_too_small_for_given_rows_raises() -> None: + with pytest.raises(cv.Invalid, match="row_count"): + TABLE_SCHEMA({"rows": [["a"], ["b"], ["c"]], "row_count": 2}) + + +def test_column_count_too_small_for_given_cells_raises() -> None: + with pytest.raises(cv.Invalid, match="column_count"): + TABLE_SCHEMA({"rows": [["a", "b", "c"]], "column_count": 2}) + + +def test_columns_list_longer_than_column_count_raises() -> None: + with pytest.raises(cv.Invalid, match="columns"): + TABLE_SCHEMA( + { + "column_count": 1, + "columns": [{"width": 10}, {"width": 20}], + } + ) + + +def test_columns_list_matching_inferred_column_count_is_valid() -> None: + config = TABLE_SCHEMA( + { + "rows": [["a", "b"]], + "columns": [{"width": 10}, {"width": 20}], + } + ) + assert [c["width"] for c in config["columns"]] == [10, 20] + + +@pytest.mark.parametrize( + ("width", "expected"), + [ + (100, 100), + ("50%", 0.5), + ("32px", 32), + ], +) +def test_column_width_accepts_pixels_and_percent(width, expected) -> None: + config = TABLE_SCHEMA({"columns": [{"width": width}]}) + assert config["columns"][0]["width"] == expected + + +def test_columns_percent_widths_summing_over_100_percent_raises() -> None: + with pytest.raises(cv.Invalid, match="columns"): + TABLE_SCHEMA({"columns": [{"width": "60%"}, {"width": "50%"}]}) + + +def test_columns_percent_widths_summing_to_100_percent_is_valid() -> None: + config = TABLE_SCHEMA({"columns": [{"width": "60%"}, {"width": "40%"}]}) + assert [c["width"] for c in config["columns"]] == [0.6, 0.4] + + +def test_columns_mixed_pixel_and_percent_widths_ignore_pixels_in_the_total() -> None: + # Pixel widths aren't part of the percentage budget, so they shouldn't + # count towards the 100% limit. + config = TABLE_SCHEMA( + {"columns": [{"width": 200}, {"width": "80%"}, {"width": "20%"}]} + ) + assert [c["width"] for c in config["columns"]] == [200, 0.8, 0.2] + + +def test_selected_row_and_selected_column_are_independently_optional() -> None: + config = TABLE_SCHEMA({"selected_row": 1}) + assert config["selected_row"] == 1 + assert "selected_column" not in config + + +def test_cell_update_action_requires_at_least_one_field() -> None: + entry = ACTION_REGISTRY["lvgl.table.cell.update"] + with pytest.raises(cv.Invalid): + entry.schema({"id": "some_table", "row": 0, "column": 0}) + + +def test_cell_update_action_accepts_a_single_field() -> None: + entry = ACTION_REGISTRY["lvgl.table.cell.update"] + config = entry.schema( + {"id": "some_table", "row": 0, "column": 0, "merge_right": True} + ) + assert config[CONF_MERGE_RIGHT] is True + assert CONF_TEXT_CROP not in config From 370e8fffed7a72e1d804827bdf8bebea4d6ddfc6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" <3060199+jesserockz@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:16:02 -0500 Subject: [PATCH 06/30] [core] Add the arduino toolchain seam and validate --toolchain on every platform (#18556) --- esphome/__main__.py | 17 +++--- esphome/compiled_config.py | 15 ++++++ esphome/components/esp32/__init__.py | 18 ++----- esphome/components/esp8266/__init__.py | 3 ++ esphome/components/host/__init__.py | 1 + esphome/components/libretiny/__init__.py | 3 +- esphome/components/nrf52/__init__.py | 11 ++-- esphome/components/rp2/__init__.py | 1 + esphome/config_validation.py | 62 ++++++++++++++++++++++ esphome/const.py | 8 +++ esphome/core/__init__.py | 16 ++++++ esphome/core/config.py | 50 +++++++++++++---- tests/component_tests/esp32/test_esp32.py | 14 +++++ tests/unit_tests/core/test_config.py | 49 +++++++++++++++++ tests/unit_tests/test_compiled_config.py | 31 +++++++++++ tests/unit_tests/test_config_validation.py | 45 ++++++++++++++++ tests/unit_tests/test_core.py | 18 +++++++ tests/unit_tests/test_main.py | 36 +++++++++++++ tests/unit_tests/test_nrf52_framework.py | 15 ++++-- 19 files changed, 370 insertions(+), 43 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 0da86b3ec0..632d2ba3d0 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -2734,10 +2734,14 @@ def run_esphome(argv): # Skipped when -s overrides are passed, since the cache was written # against the previous substitution set. config: ConfigType | None = None - cache_eligible = ( + cache_write_eligible = ( args.command in ("upload", "logs") and not command_line_substitutions ) - if cache_eligible: + # An explicit --toolchain must re-run the per-platform validators, so + # gate only the cache read; the refresh below saves the result unless + # the sidecar records a different toolchain. + cache_read_eligible = cache_write_eligible and args.toolchain is None + if cache_read_eligible: from esphome.compiled_config import load_compiled_config config = load_compiled_config(conf_path) @@ -2761,17 +2765,14 @@ 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. Must run before the - # cache refresh below so its sidecar records the same toolchain a - # compile would. + # The cache fast path skips validation, and legacy sidecars lack the + # toolchain field. Must run before the cache refresh below. if CORE.toolchain is None: CORE.toolchain = Toolchain.PLATFORMIO # Refresh the cache so the next upload/logs hits the fast path # instead of re-running read_config. - if cache_eligible and cache_missed: + if cache_write_eligible and cache_missed: from esphome.compiled_config import save_compiled_config_and_sidecar save_compiled_config_and_sidecar(config) diff --git a/esphome/compiled_config.py b/esphome/compiled_config.py index be03eea965..0d855d71db 100644 --- a/esphome/compiled_config.py +++ b/esphome/compiled_config.py @@ -100,6 +100,21 @@ def _refresh_sidecar() -> bool: ) return False if old is not None and old.can_apply_to_core(): + if ( + old.toolchain is not None + and CORE.toolchain is not None + and old.toolchain != CORE.toolchain.value + ): + # Platforms normalize toolchain-sensitive keys differently; + # never cache a config validated under a different toolchain + # than the compile's + _LOGGER.debug( + "Not caching: config validated with toolchain %r but the " + "last compile used %r", + CORE.toolchain.value, + old.toolchain, + ) + return False # Compile-written; nothing to refresh. return True if CORE.build_path is not None and CORE.build_path.exists(): diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 073d87402a..bc91f29a42 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1105,19 +1105,11 @@ def _check_esp_idf_versions(config: ConfigType) -> ConfigType: return config -def _validate_toolchain(value) -> Toolchain: - return Toolchain( - cv.one_of(Toolchain.PLATFORMIO, Toolchain.ESP_IDF, lower=True)(value) - ) - - -def _resolve_toolchain(value: ConfigType) -> ConfigType: - # Resolve toolchain: CLI (already on CORE.toolchain) > YAML > default. - # Runs before _detect_variant so downstream validators can rely on - # CORE.toolchain instead of re-resolving it from the config dict. - if CORE.toolchain is None: - CORE.toolchain = value.get(CONF_TOOLCHAIN, Toolchain.ESP_IDF) - return value +_TOOLCHAINS = (Toolchain.PLATFORMIO, Toolchain.ESP_IDF) +_validate_toolchain = cv.toolchain_enum(_TOOLCHAINS) +# Runs before _detect_variant so downstream validators can rely on +# CORE.toolchain instead of re-resolving it from the config dict. +_resolve_toolchain = cv.resolve_toolchain("ESP32", _TOOLCHAINS, Toolchain.ESP_IDF) def _check_versions(config: ConfigType) -> ConfigType: diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 75483c5293..6f29cd7774 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -247,6 +247,9 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ENABLE_SCANF_FLOAT): cv.boolean, } ), + # Until the native toolchain lands, PlatformIO is the only backend; + # reject a --toolchain this platform cannot serve yet. + cv.require_platformio_toolchain("ESP8266"), set_core_data, ) diff --git a/esphome/components/host/__init__.py b/esphome/components/host/__init__.py index c5846f5406..401bba5118 100644 --- a/esphome/components/host/__init__.py +++ b/esphome/components/host/__init__.py @@ -37,6 +37,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_MAC_ADDRESS, default="98:35:69:ab:f6:79"): cv.mac_address, } ), + cv.require_platformio_toolchain("host"), set_core_data, ) diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index c56cc48055..50dc787799 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -300,7 +300,7 @@ FRAMEWORK_SCHEMA = cv.All( _check_debug_order, ) -CONFIG_SCHEMA = cv.All(_notify_old_style) +CONFIG_SCHEMA = cv.All(_notify_old_style, cv.require_platformio_toolchain("LibreTiny")) BASE_SCHEMA = cv.Schema( { @@ -314,6 +314,7 @@ BASE_SCHEMA = cv.Schema( ) BASE_SCHEMA.add_extra(_detect_variant) +BASE_SCHEMA.add_extra(cv.require_platformio_toolchain("LibreTiny")) BASE_SCHEMA.add_extra(_update_core_data) diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index 2d25558254..aeeaba0c11 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -125,10 +125,8 @@ def set_core_data(config: ConfigType) -> ConfigType: return config -def _resolve_toolchain(config: ConfigType) -> ConfigType: - if CORE.toolchain is None: - CORE.toolchain = config.get(CONF_TOOLCHAIN, Toolchain.SDK_NRF) - return config +_TOOLCHAINS = (Toolchain.PLATFORMIO, Toolchain.SDK_NRF) +_resolve_toolchain = cv.resolve_toolchain("nRF52", _TOOLCHAINS, Toolchain.SDK_NRF) def set_framework(config: ConfigType) -> ConfigType: @@ -170,10 +168,7 @@ BOOTLOADERS = [ ] -def _validate_toolchain(value) -> Toolchain: - return Toolchain( - cv.one_of(Toolchain.PLATFORMIO, Toolchain.SDK_NRF, lower=True)(value) - ) +_validate_toolchain = cv.toolchain_enum(_TOOLCHAINS) def _detect_bootloader(config: ConfigType) -> ConfigType: diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index ed975ec01a..dae7df26c3 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -312,6 +312,7 @@ CONFIG_SCHEMA = cv.All( ), cv.has_at_least_one_key(CONF_BOARD, CONF_VARIANT), _detect_variant, + cv.require_platformio_toolchain("RP2"), set_core_data, ) diff --git a/esphome/config_validation.py b/esphome/config_validation.py index f455c7b8bf..98001d5d5b 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -53,6 +53,7 @@ from esphome.const import ( CONF_SETUP_PRIORITY, CONF_STATE_TOPIC, CONF_SUBSCRIBE_QOS, + CONF_TOOLCHAIN, CONF_TOPIC, CONF_TYPE, CONF_TYPE_ID, @@ -75,6 +76,7 @@ from esphome.const import ( TYPE_GIT, TYPE_LOCAL, Framework, + Toolchain, __version__ as ESPHOME_VERSION, ) from esphome.core import ( @@ -106,6 +108,9 @@ from esphome.util import parse_esphome_version # noqa: F401 from esphome.voluptuous_schema import _Schema from esphome.yaml_util import SensitiveStr, make_data_base +if typing.TYPE_CHECKING: + from esphome.types import ConfigType + _LOGGER = logging.getLogger(__name__) # pylint: disable=invalid-name @@ -2532,6 +2537,63 @@ def platformio_version_constraint(value): return constraints +def _check_supported_toolchain( + platform_name: str, supported: tuple[Toolchain, ...] +) -> None: + """Raise when the resolved ``CORE.toolchain`` is not in ``supported`` + (one message shape for every platform).""" + toolchain = CORE.toolchain + if toolchain is None: + # A caller ran the check before resolving; an ordering bug, not a + # user error + raise Invalid(f"Toolchain was not resolved before {platform_name} validation") + if toolchain not in supported: + names = ", ".join(f"'{tc.value}'" for tc in supported) + raise Invalid( + f"Unsupported toolchain " + f"'{toolchain.value}' for " + f"{platform_name}. Supported: {names}." + ) + + +def toolchain_enum(supported: tuple[Toolchain, ...]) -> Callable[[str], Toolchain]: + """Schema validator for a platform's ``toolchain`` config key.""" + + def validator(value: str) -> Toolchain: + return Toolchain(one_of(*supported, lower=True)(value)) + + return validator + + +def resolve_toolchain( + platform_name: str, supported: tuple[Toolchain, ...], default: Toolchain +) -> Callable[[ConfigType], ConfigType]: + """Resolve ``CORE.toolchain`` (CLI > YAML > default) and reject one the + platform cannot serve. + + Add to the platform's validation chain before anything that reads + ``CORE.toolchain``. + """ + + def validator(config: ConfigType) -> ConfigType: + if CORE.toolchain is None: + CORE.toolchain = config.get(CONF_TOOLCHAIN, default) + _check_supported_toolchain(platform_name, supported) + return config + + return validator + + +def require_platformio_toolchain( + platform_name: str, +) -> Callable[[ConfigType], ConfigType]: + """Reject a CLI-selected toolchain other than PlatformIO, for platforms + with only the PlatformIO backend.""" + return resolve_toolchain( + platform_name, (Toolchain.PLATFORMIO,), Toolchain.PLATFORMIO + ) + + def require_framework_version( *, max_version=False, diff --git a/esphome/const.py b/esphome/const.py index 0dd948544f..6f83f0c937 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -21,6 +21,14 @@ class Toolchain(StrEnum): PLATFORMIO = "platformio" ESP_IDF = "esp-idf" SDK_NRF = "sdk-nrf" + # ESP8266: the Arduino core built directly (no PlatformIO) + ARDUINO = "arduino" + + +# Toolchains that drive their build natively and never read platformio.ini. +# SDK_NRF is absent on purpose: the zephyr backend keeps consuming +# platformio_options. +NATIVE_TOOLCHAINS = frozenset({Toolchain.ESP_IDF, Toolchain.ARDUINO}) class Platform(StrEnum): diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 0f1ac9213e..2ec2a08e83 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -21,6 +21,7 @@ from esphome.const import ( KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, + NATIVE_TOOLCHAINS, PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, @@ -982,6 +983,19 @@ class EsphomeCore: def using_toolchain_sdk_nrf(self): return self.toolchain == Toolchain.SDK_NRF + @property + def using_toolchain_arduino(self): + """The native ESP8266 Arduino build toolchain (unlike + ``using_arduino``, which is the target framework).""" + return self.toolchain == Toolchain.ARDUINO + + @property + def using_native_toolchain(self): + """Whether the selected toolchain builds natively, without reading + ``platformio.ini`` (see ``NATIVE_TOOLCHAINS`` in ``esphome.const``; + keep its membership in sync with ``write_cpp_file``'s dispatch).""" + return self.toolchain in NATIVE_TOOLCHAINS + @property def using_zephyr(self): return self.target_framework == "zephyr" @@ -1095,6 +1109,8 @@ class EsphomeCore: return build_flag def add_build_unflag(self, build_unflag: str) -> None: + # No warning for using_toolchain_arduino: the native ESP8266 build + # honors build_unflags (token-level, matching PlatformIO). if self.using_toolchain_esp_idf: # The native ESP-IDF build generator does not consume build_unflags _LOGGER.warning( diff --git a/esphome/core/config.py b/esphome/core/config.py index 1095a4886e..472ca64c9a 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -555,12 +555,24 @@ def _add_library_str(lib: str) -> None: cg.add_library(lib, None) +# platformio_options keys the native ESP8266 Arduino generator (a later PR +# in this chain) will honor; its ignored-option warning will consume the same +# list so the two cannot drift +NATIVE_ARDUINO_PIO_OPTIONS = frozenset({"board_build.f_cpu", "board_build.ldscript"}) +# The full set that survives into CORE.platformio_options under the native +# arduino toolchain: lib_ignore is the only specially-translated key below +# that is stored rather than translated away. Consumed by the esp8266 native +# backend (later in this chain) for its ignored-option warning; defined here +# so it stays adjacent to the routing. +NATIVE_ARDUINO_CONSUMED_PIO_OPTIONS = NATIVE_ARDUINO_PIO_OPTIONS | {"lib_ignore"} + + @coroutine_with_priority(CoroPriority.FINAL) async def _add_platformio_options(pio_options: dict[str, str | list[str]]) -> None: - if CORE.using_toolchain_esp_idf: - # The native ESP-IDF build doesn't read platformio.ini; honor the - # options with a native equivalent and warn about the rest, which - # would otherwise be silently ignored. + if CORE.using_native_toolchain: + # The native builds don't read platformio.ini; honor the options + # with a native equivalent and warn about the rest, which would + # otherwise be silently ignored. for key, val in pio_options.items(): vals = [val] if isinstance(val, str) else val if key == CONF_BUILD_FLAGS: @@ -573,23 +585,41 @@ async def _add_platformio_options(pio_options: dict[str, str | list[str]]) -> No ) for flag in vals: cg.add_build_flag(flag) + elif key == "build_unflags": + # Native equivalent: add_build_unflag (honored token-level by + # the arduino generator; the IDF generator warns there) + for flag in vals: + CORE.add_build_unflag(flag) elif key == "lib_deps": - # Routed through the regular library mechanism so the libraries - # are converted to IDF components like any other PIO library + # Routed through the regular library mechanism so the + # libraries reach the native backend's converter (IDF + # components, or the ESP8266 native library resolution) for lib in vals: _add_library_str(lib) elif key == "lib_ignore": - # Read by the PIO-library-to-IDF-component conversion - # (generate_idf_components); filters both top-level libraries - # and dependencies discovered during conversion + # Read by the shared library conversion (lib_ignore_set in + # platformio/library.py); filters top-level libraries and + # discovered dependencies cg.add_platformio_option(key, vals) + elif ( + key in NATIVE_ARDUINO_PIO_OPTIONS + and CORE.using_toolchain_arduino + and vals + ): + # The esp8266 native generator reads these as scalars; the + # schema also permits the list form, where the last value + # wins like a later platformio.ini line (an empty list falls + # through to the ignored-option warning). Other native + # toolchains have no equivalent and fall through too. + cg.add_platformio_option(key, vals[-1]) elif key != "upload_speed": # upload_speed needs no handling: it is read from the raw # config at upload time (upload_using_esptool) _LOGGER.warning( "esphome->platformio_options->%s is ignored when building with " - "the native ESP-IDF toolchain", + "the native '%s' toolchain", key, + CORE.toolchain.value, ) return # Add includes at the very end, so that they override everything diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 0ffbe16a17..297844b4e6 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -132,6 +132,20 @@ def test_esp32_rejects_unsupported_toolchains( CONFIG_SCHEMA({"variant": VARIANT_ESP32, "toolchain": config_toolchain}) +def test_esp32_rejects_unsupported_cli_toolchain( + set_core_config: SetCoreConfigCallable, +) -> None: + """A --toolchain the platform cannot serve fails instead of silently + building with PlatformIO (the CLI path bypasses the YAML validator).""" + set_core_config(PlatformFramework.ESP32_IDF) + + from esphome.components.esp32 import CONFIG_SCHEMA + + CORE.toolchain = Toolchain.ARDUINO + with pytest.raises(cv.Invalid, match="Unsupported toolchain 'arduino'"): + CONFIG_SCHEMA({"variant": VARIANT_ESP32}) + + @pytest.mark.parametrize( ("config", "error_match"), [ diff --git a/tests/unit_tests/core/test_config.py b/tests/unit_tests/core/test_config.py index e09edd7f26..e620f8ec7f 100644 --- a/tests/unit_tests/core/test_config.py +++ b/tests/unit_tests/core/test_config.py @@ -1285,6 +1285,7 @@ async def test_add_platformio_options_native_idf( await config._add_platformio_options( { "build_flags": "-DSINGLE_FLAG", # string and list forms both valid + "build_unflags": ["-Os"], "lib_deps": ["bblanchon/ArduinoJson@7.4.2"], "lib_ignore": "libsodium", "upload_speed": "115200", @@ -1294,6 +1295,7 @@ async def test_add_platformio_options_native_idf( assert "-DSINGLE_FLAG" in CORE.build_flags assert "ArduinoJson" in CORE.platformio_libraries + assert "-Os" in CORE.build_unflags # lib_ignore is stored (listified) for generate_idf_components to read; # nothing else lands in platformio_options on the native toolchain. assert CORE.platformio_options == {"lib_ignore": ["libsodium"]} @@ -1389,3 +1391,50 @@ def test_esphome_build_internals_are_yaml_only() -> None: assert markers[field].visibility is cv.Visibility.ADVANCED, field # A regular device-config field stays on the main form. assert markers[CONF_NAME_ADD_MAC_SUFFIX].visibility is None + + +@pytest.mark.asyncio +async def test_add_platformio_options_native_arduino( + caplog: pytest.LogCaptureFixture, +) -> None: + """The native ESP8266 Arduino toolchain honors board_build.f_cpu (a + real-world overclock knob) and warns about the rest like native IDF.""" + CORE.toolchain = Toolchain.ARDUINO + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: "esp8266", + KEY_TARGET_FRAMEWORK: "arduino", + } + + await config._add_platformio_options( + { + "board_build.f_cpu": "160000000L", + # The schema also permits the list form; the last value wins + # and reaches the generator as a scalar + "board_build.ldscript": ["eagle.flash.2m.ld", "eagle.flash.4m2m.ld"], + "board_build.filesystem": "littlefs", + "upload_speed": "115200", + } + ) + + assert CORE.platformio_options["board_build.f_cpu"] == "160000000L" + assert CORE.platformio_options["board_build.ldscript"] == "eagle.flash.4m2m.ld" + assert "board_build.f_cpu is ignored" not in caplog.text + assert "board_build.ldscript is ignored" not in caplog.text + assert ( + "esphome->platformio_options->board_build.filesystem is ignored" in caplog.text + ) + # An empty list for an honored key is not a scalar; it falls through + # to the ignored-option warning instead of an IndexError + await config._add_platformio_options({"board_build.ldscript": []}) + assert "board_build.ldscript is ignored" in caplog.text + assert "'arduino' toolchain" in caplog.text + assert "upload_speed" not in caplog.text + + +def test_esp8266_rejects_unsupported_cli_toolchain() -> None: + """Until the native backend lands, ESP8266 serves only PlatformIO.""" + from esphome.components.esp8266 import CONFIG_SCHEMA + + CORE.toolchain = Toolchain.ARDUINO + with pytest.raises(cv.Invalid, match="Unsupported toolchain 'arduino'"): + CONFIG_SCHEMA({"board": "nodemcuv2"}) diff --git a/tests/unit_tests/test_compiled_config.py b/tests/unit_tests/test_compiled_config.py index 77690a6897..4333420a9e 100644 --- a/tests/unit_tests/test_compiled_config.py +++ b/tests/unit_tests/test_compiled_config.py @@ -68,6 +68,7 @@ def _write_storage( esp_platform: str | None = "ESP32", core_platform: str | None = "esp32", build_path: str | None = "/build/lite_test", + toolchain: str | None = None, ) -> None: """Write a vanilla StorageJSON sidecar for the cache tests.""" storage_path.parent.mkdir(parents=True, exist_ok=True) @@ -88,6 +89,7 @@ def _write_storage( "no_mdns": False, "framework": "arduino", "core_platform": core_platform, + "toolchain": toolchain, } storage_path.write_text(json.dumps(data), encoding="utf-8") @@ -629,6 +631,35 @@ def test_save_compiled_config_and_sidecar_builds_real_sidecar(tmp_path: Path) -> assert load_compiled_config(yaml_path) is not None +@pytest.mark.parametrize( + ("sidecar_toolchain", "saved"), + [ + ("esp-idf", False), + ("platformio", True), + (None, True), # legacy sidecar without the field: guard is inert + ], +) +def test_save_compiled_config_and_sidecar_toolchain_mismatch( + tmp_path: Path, sidecar_toolchain: str | None, saved: bool +) -> None: + """A config validated under a different toolchain than the compile's + must not overwrite the cache.""" + yaml_path = _bare_yaml(tmp_path) + _prime_core(tmp_path) + CORE.config = {CONF_ESPHOME: {CONF_NAME: "lite_test"}} + CORE.toolchain = Toolchain.PLATFORMIO + _write_storage( + tmp_path / ".esphome" / "storage" / "lite_test.yaml.json", + toolchain=sidecar_toolchain, + ) + + save_compiled_config_and_sidecar(CORE.config) + + cache = tmp_path / ".esphome" / "storage" / "lite_test.yaml.validated.json" + assert cache.exists() is saved + assert (load_compiled_config(yaml_path) is not None) is saved + + @pytest.mark.parametrize("command", ["upload", "logs"]) def test_run_esphome_upload_and_logs_refresh_cache_on_fallback( tmp_path: Path, command: str diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 971c4e462d..0f927a6513 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -1,3 +1,4 @@ +import importlib import json import logging from pathlib import Path @@ -48,6 +49,7 @@ from esphome.const import ( TYPE_GIT, TYPE_LOCAL, Framework, + Toolchain, ) from esphome.core import ( CORE, @@ -3165,3 +3167,46 @@ def test_file__remapped_path_is_directory_raises(setup_core: Path) -> None: with pytest.raises(Invalid, match="is not a file"): cv.file_("/original/config/headers") + + +def test_require_platformio_toolchain() -> None: + """Platforms with only the PlatformIO backend reject other toolchains.""" + validator = cv.require_platformio_toolchain("RP2") + CORE.toolchain = None + config: dict = {} + assert validator(config) is config + assert CORE.toolchain == Toolchain.PLATFORMIO + + CORE.toolchain = Toolchain.ARDUINO + with pytest.raises(Invalid, match="Unsupported toolchain 'arduino' for RP2"): + validator(config) + + +def test_check_supported_toolchain_unresolved_is_an_ordering_bug() -> None: + """Calling the check before resolution fails naming the ordering bug, + not a user-facing unsupported-toolchain error.""" + CORE.toolchain = None + with pytest.raises(Invalid, match="not resolved before RP2 validation"): + cv._check_supported_toolchain("RP2", (Toolchain.PLATFORMIO,)) + + +@pytest.mark.parametrize( + ("platform", "minimal_config"), + [ + ("host", {}), + ("rp2", {"board": "rpipicow"}), + ("bk72xx", {"board": "generic-bk7231n-qfn32-tuya"}), + ("rtl87xx", {"board": "generic-rtl8710bn-2mb-788k"}), + ("ln882x", {"board": "generic-ln882h"}), + # The legacy stub platform must reject too, not just the chip families + ("libretiny", {}), + ], +) +def test_every_platformio_only_platform_rejects_arduino_toolchain( + platform: str, minimal_config: dict +) -> None: + """A platform that cannot serve a CLI toolchain rejects it at validation.""" + module = importlib.import_module(f"esphome.components.{platform}") + CORE.toolchain = Toolchain.ARDUINO + with pytest.raises(Invalid, match="Unsupported toolchain 'arduino'"): + module.CONFIG_SCHEMA(dict(minimal_config)) diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 7adf955217..0c96f8c8c9 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -958,6 +958,24 @@ class TestEsphomeCore: target.toolchain = const.Toolchain.ESP_IDF assert target.using_toolchain_sdk_nrf is False + def test_using_toolchain_arduino(self, target): + """A toolchain choice, distinct from the arduino target framework.""" + target.toolchain = const.Toolchain.ARDUINO + assert target.using_toolchain_arduino is True + target.toolchain = const.Toolchain.PLATFORMIO + assert target.using_toolchain_arduino is False + + def test_using_native_toolchain(self, target): + """True exactly for the toolchains that never read platformio.ini.""" + target.toolchain = const.Toolchain.ESP_IDF + assert target.using_native_toolchain is True + target.toolchain = const.Toolchain.ARDUINO + assert target.using_native_toolchain is True + target.toolchain = const.Toolchain.PLATFORMIO + assert target.using_native_toolchain is False + target.toolchain = const.Toolchain.SDK_NRF + assert target.using_native_toolchain is False + def test_add_library__extracts_short_name_from_path(self, target): """Test add_library extracts short name from library paths like owner/lib.""" target.data[const.KEY_CORE] = { diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index c7a5c85638..08c99e2119 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -7195,6 +7195,42 @@ def test_compile_program_espidf_idedata_none_warns( assert "No idedata was generated" in caplog.text +def test_cli_toolchain_skips_the_validated_config_cache(tmp_path: Path) -> None: + """An explicit --toolchain must run the per-platform validators, so the + upload/logs fast path becomes a cache miss.""" + conf = tmp_path / "device.yaml" + conf.write_text("esphome:\n name: t\n") + argv = ["esphome", "--toolchain", "arduino", "logs", str(conf)] + with ( + patch("esphome.compiled_config.load_compiled_config") as mock_cache, + patch("esphome.config.read_config", return_value=None) as mock_read, + ): + assert run_esphome(argv) == 2 + mock_cache.assert_not_called() + mock_read.assert_called_once() + + +def test_cli_toolchain_still_refreshes_the_validated_config_cache( + tmp_path: Path, +) -> None: + """An explicit --toolchain gates only the cache read; with a matching + sidecar the freshly validated config is still saved.""" + conf = tmp_path / "device.yaml" + conf.write_text("esphome:\n name: t\n") + argv = ["esphome", "--toolchain", "platformio", "logs", str(conf)] + with ( + patch("esphome.compiled_config.load_compiled_config") as mock_load, + patch("esphome.config.read_config", return_value={CONF_ESPHOME: {}}), + patch("esphome.compiled_config.save_compiled_config_and_sidecar") as mock_save, + patch.dict( + "esphome.__main__.POST_CONFIG_ACTIONS", {"logs": Mock(return_value=0)} + ), + ): + assert run_esphome(argv) == 0 + mock_load.assert_not_called() + mock_save.assert_called_once() + + @pytest.mark.asyncio async def test_wrap_to_code_comment_is_insertion_order_independent() -> None: """The config comment dumps with sorted keys: voluptuous fills schema diff --git a/tests/unit_tests/test_nrf52_framework.py b/tests/unit_tests/test_nrf52_framework.py index 7b83a1edc7..b78a94a2e7 100644 --- a/tests/unit_tests/test_nrf52_framework.py +++ b/tests/unit_tests/test_nrf52_framework.py @@ -7,8 +7,10 @@ import sys from types import SimpleNamespace from unittest.mock import patch +import platformdirs import pytest +from esphome.components.nrf52 import _resolve_toolchain from esphome.components.nrf52.framework import ( _PLATFORMIO_PENV_REQUIREMENTS, _REQUIREMENTS, @@ -22,8 +24,9 @@ from esphome.components.nrf52.framework import ( get_sdk_nrf_tools_path, setup_platformio_python_env, ) +import esphome.config_validation as cv from esphome.config_validation import Version -from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION +from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION, Toolchain from esphome.core import CORE, EsphomeError from esphome.framework_helpers import get_python_env_executable_path @@ -560,7 +563,6 @@ def testget_tools_path_blank_env_falls_back_to_default( Path("") would resolve to the working directory, which clean-all could then delete by accident. """ - import platformdirs monkeypatch.setenv("ESPHOME_SDK_NRF_PREFIX", value) expected = ( @@ -572,7 +574,6 @@ def testget_tools_path_blank_env_falls_back_to_default( def testget_tools_path_default_is_global_cache( monkeypatch: pytest.MonkeyPatch, ) -> None: - import platformdirs monkeypatch.delenv("ESPHOME_SDK_NRF_PREFIX", raising=False) expected = ( @@ -621,3 +622,11 @@ def test_needs_venv_rebuild_on_dangling_interpreter_symlink(tmp_path: Path) -> N assert not python.exists() assert _needs_venv_rebuild(python, sentinel, "abc123") + + +def test_resolve_toolchain_rejects_unsupported() -> None: + """A --toolchain nRF52 cannot serve fails instead of degrading silently.""" + + CORE.toolchain = Toolchain.ARDUINO + with pytest.raises(cv.Invalid, match="Unsupported toolchain 'arduino'"): + _resolve_toolchain({}) From 7ff56c62f9c89b14d1d22d14614a3506003c8a22 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" <3060199+jesserockz@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:23:16 -0500 Subject: [PATCH 07/30] [core] Add shared registry, ninja, and cache infrastructure for native toolchains (#18570) --- esphome/build_helpers/ccache.py | 92 +++ esphome/build_helpers/ninja.py | 92 +++ esphome/build_helpers/tools_cache.py | 36 + esphome/components/nrf52/framework.py | 16 +- esphome/config_validation.py | 12 +- esphome/espidf/framework.py | 102 +-- esphome/framework_helpers.py | 58 ++ esphome/helpers.py | 3 +- esphome/platformio/registry.py | 311 ++++++++ esphome/platformio/toolchain.py | 88 +-- esphome/writer.py | 11 +- requirements.txt | 1 + tests/unit_tests/build_helpers/test_ccache.py | 122 +++ tests/unit_tests/build_helpers/test_ninja.py | 143 ++++ tests/unit_tests/test_espidf_framework.py | 111 ++- tests/unit_tests/test_framework_helpers.py | 34 + tests/unit_tests/test_platformio_registry.py | 725 ++++++++++++++++++ tests/unit_tests/test_platformio_toolchain.py | 89 +-- tests/unit_tests/test_writer.py | 16 +- 19 files changed, 1830 insertions(+), 232 deletions(-) create mode 100644 esphome/build_helpers/ccache.py create mode 100644 esphome/build_helpers/ninja.py create mode 100644 esphome/build_helpers/tools_cache.py create mode 100644 esphome/platformio/registry.py create mode 100644 tests/unit_tests/build_helpers/test_ccache.py create mode 100644 tests/unit_tests/build_helpers/test_ninja.py create mode 100644 tests/unit_tests/test_platformio_registry.py diff --git a/esphome/build_helpers/ccache.py b/esphome/build_helpers/ccache.py new file mode 100644 index 0000000000..5b5c7f247f --- /dev/null +++ b/esphome/build_helpers/ccache.py @@ -0,0 +1,92 @@ +"""Shared ccache policy for build backends: env-knob parsing, binary +resolution, and default ``CCACHE_*`` values.""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path + +from esphome.framework_helpers import strip_win_long_path_prefix, tool_version_runs +from esphome.helpers import FALSY_ENV_STRINGS, TRUTHY_ENV_STRINGS + +_LOGGER = logging.getLogger(__name__) + + +def _ccache_runs(ccache: str) -> bool: + """Return True when the ``ccache`` found on PATH actually runs.""" + return tool_version_runs( + ccache, + "Ignoring ccache at %s because it failed to run; compiling without ccache", + ) + + +def parse_enable_env(name: str) -> bool | None: + """Strictly parse an on/off environment knob; None when unset or invalid. + + ``bool(str)`` truthiness would flip ``no``/``off`` to enabled, so only + 1/true/yes/on and 0/false/no/off count; anything else warns and reads + as unset so the caller's default policy applies. + """ + raw = os.environ.get(name) + if raw is None: + return None + lowered = raw.strip().lower() + if not lowered: + # ENV KNOB= (Docker/CI) has always read as a disable + return False + if lowered in TRUTHY_ENV_STRINGS: + return True + if lowered in FALSY_ENV_STRINGS: + return False + _LOGGER.warning("Ignoring unrecognized %s=%r; use 1 or 0", name, raw) + return None + + +def resolve_ccache_path() -> str | None: + """The ccache binary to wrap compiles with, or None when disabled. + + An explicit ``ESPHOME_CCACHE_ENABLE=1`` skips the runnability probe; the + Windows extended-length prefix is stripped before probing (#18399). + """ + import shutil + + explicit = parse_enable_env("ESPHOME_CCACHE_ENABLE") + if explicit is False: + return None + ccache = shutil.which("ccache") + if ccache is None: + if explicit: + _LOGGER.warning( + "ESPHOME_CCACHE_ENABLE is set but no ccache binary is on PATH; " + "compiling without ccache" + ) + return None + ccache = strip_win_long_path_prefix(ccache) + if not explicit and not _ccache_runs(ccache): + return None + return ccache + + +def ccache_defaults_env(cache_dir: Path) -> dict[str, str]: + """Default ``CCACHE_*`` values for a build subprocess (not os.environ). + + Values the user already set in the environment are respected. Depend + mode is on: both native backends emit depfiles (-MMD / CMake), which + keeps cache-miss overhead low. + """ + from esphome.core import CORE + + # An unset build_path means the env was built before preload; fail loudly + # rather than silently drop CCACHE_BASEDIR. + if CORE.build_path is None: + raise ValueError( + "CORE.build_path must be set before constructing the build environment" + ) + defaults = { + "CCACHE_DIR": str(cache_dir), + "CCACHE_NOHASHDIR": "true", + "CCACHE_DEPEND": "1", + "CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()), + } + return {k: v for k, v in defaults.items() if k not in os.environ} diff --git a/esphome/build_helpers/ninja.py b/esphome/build_helpers/ninja.py new file mode 100644 index 0000000000..8c25bc9513 --- /dev/null +++ b/esphome/build_helpers/ninja.py @@ -0,0 +1,92 @@ +"""Platform-neutral helpers for ninja-driven native builds.""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +import re +import shutil + +from esphome.core import EsphomeError +from esphome.framework_helpers import strip_win_long_path_prefix, tool_version_runs + +_LOGGER = logging.getLogger(__name__) + + +def _ninja_runs(binary: str) -> bool: + """Whether the ninja found on PATH actually runs (see tool_version_runs).""" + return tool_version_runs( + binary, + "Ignoring ninja at %s because it failed to run; " + "falling back to the bundled wheel", + ) + + +def find_ninja() -> Path: + """Locate the ninja binary: a runnable PATH hit first, else the ninja + PyPI wheel.""" + if binary := shutil.which("ninja"): + binary = strip_win_long_path_prefix(binary) + if _ninja_runs(binary): + return Path(binary) + import_error: ImportError | None = None + try: + import ninja + except ImportError as err: + import_error = err + wheel_binary = None + else: + wheel_binary = Path(ninja.BIN_DIR) / ( + "ninja.exe" if os.name == "nt" else "ninja" + ) + if wheel_binary is None or not wheel_binary.is_file(): + raise EsphomeError( + "ninja not found on PATH or in the ninja package; reinstall the " + "esphome Python environment" + ) from import_error + return wheel_binary + + +def escape(value: Path | str) -> str: + """Escape a path or token for a ninja file.""" + return str(value).replace("$", "$$").replace(":", "$:").replace(" ", "$ ") + + +def quote_arg(tok: str) -> str: + """Quote with the CreateProcess argv rule (as ``subprocess.list2cmdline``): + backslash runs double only before a quote. Windows-only; ``$`` must + already be doubled for ninja. + """ + quoted = re.sub(r'(\\*)"', lambda m: m.group(1) * 2 + '\\"', tok) + quoted = re.sub(r"(\\+)\Z", lambda m: m.group(1) * 2, quoted) + return f'"{quoted}"' + + +# Force-quote any token containing a character outside the shlex.quote-style +# safe set: ninja hands POSIX commands to /bin/sh -c, so bare (, ;, <, *, ` +# and friends would be re-parsed as shell syntax. +_NEEDS_QUOTE = re.compile(r"[^\w@%+=:,./-]") + + +def shell_token(tok: str, force: bool = False) -> str: + """Re-quote a lexed token for the platform shell; ``force`` always quotes. + + Single quotes on POSIX (/bin/sh), the argv rule on Windows + (CreateProcess). ``$`` is doubled first because ninja expands it before + the command reaches the shell. + """ + tok = tok.replace("$", "$$") # ninja would expand a bare $ to nothing + if not (force or not tok or _NEEDS_QUOTE.search(tok)): + return tok + # An empty token must become '' / "" or it vanishes from the argv + if os.name == "nt": + return quote_arg(tok) + # shlex.quote's rule; inlined because the $-doubled token must not be + # re-examined for safe characters + return "'" + tok.replace("'", "'\"'\"'") + "'" + + +def quote_path(value: Path | str) -> str: + """Force-quote a path for the ninja command line (shell/CreateProcess).""" + return shell_token(str(value), force=True) diff --git a/esphome/build_helpers/tools_cache.py b/esphome/build_helpers/tools_cache.py new file mode 100644 index 0000000000..e7193a8e2a --- /dev/null +++ b/esphome/build_helpers/tools_cache.py @@ -0,0 +1,36 @@ +"""Machine-global tools cache location shared by the native backends.""" + +from __future__ import annotations + +from pathlib import Path + + +def tools_cache_path(env_var: str, subdir: str) -> Path: + """A backend's machine-global tools directory, with an env override. + + A blank/whitespace override is treated as unset: ``Path("")`` resolves + to the CWD, which ``clean-all`` would then delete. + """ + import platformdirs + + from esphome.helpers import get_str_env + + if prefix := get_str_env(env_var, "").strip(): + # resolve(): symlinked prefixes otherwise trip idf.py's + # venv-mismatch warning on every build + return Path(prefix).expanduser().resolve() + # appauthor=False keeps the Windows path short (no vendor segment); + # deep IDF trees run into MAX_PATH otherwise + return ( + Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / subdir + ).resolve() + + +# (env override, cache subdir) per native backend. writer.clean_all wipes +# every entry via tools_cache_path, so listing a cache here is the single +# step that registers it for removal; the backends' own path getters use +# the same named pairs so the two cannot drift. +IDF_TOOLS_CACHE = ("ESPHOME_ESP_IDF_PREFIX", "idf") +SDK_NRF_TOOLS_CACHE = ("ESPHOME_SDK_NRF_PREFIX", "sdk-nrf") +ARDUINO8266_TOOLS_CACHE = ("ESPHOME_ARDUINO8266_PREFIX", "arduino8266") +TOOLS_CACHE_SPECS = (IDF_TOOLS_CACHE, SDK_NRF_TOOLS_CACHE, ARDUINO8266_TOOLS_CACHE) diff --git a/esphome/components/nrf52/framework.py b/esphome/components/nrf52/framework.py index e24569e322..5e2cf197fb 100644 --- a/esphome/components/nrf52/framework.py +++ b/esphome/components/nrf52/framework.py @@ -6,8 +6,7 @@ import platform import shutil import sys -import platformdirs - +from esphome.build_helpers.tools_cache import SDK_NRF_TOOLS_CACHE, tools_cache_path import esphome.config_validation as cv from esphome.const import KEY_CORE, KEY_FRAMEWORK_VERSION from esphome.core import CORE, EsphomeError @@ -19,7 +18,6 @@ from esphome.framework_helpers import ( run_command_ok, str_to_lst_of_str, ) -from esphome.helpers import get_str_env _LOGGER = logging.getLogger(__name__) @@ -49,15 +47,9 @@ SDK_NG_MINIMAL_MIRRORS = str_to_lst_of_str( def get_sdk_nrf_tools_path() -> Path: - # A blank ESPHOME_SDK_NRF_PREFIX must be treated as unset: Path("") - # resolves to the CWD, which clean-all would then delete. - if prefix := get_str_env("ESPHOME_SDK_NRF_PREFIX", "").strip(): - path = Path(prefix).expanduser() - else: - # Machine-global (OS user cache dir) so all projects share one install; - # see espidf.framework.get_idf_tools_path for the location rationale. - path = Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "sdk-nrf" - return path.resolve() + # Machine-global (OS user cache dir) so all projects share one install; + # see espidf.framework.get_idf_tools_path for the location rationale. + return tools_cache_path(*SDK_NRF_TOOLS_CACHE) def _needs_venv_rebuild( diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 98001d5d5b..09962e8c95 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -93,7 +93,13 @@ from esphome.core import ( ) 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.helpers import ( + FALSY_BOOL_STRINGS, + TRUTHY_BOOL_STRINGS, + add_class_to_obj, + docs_url, + list_starts_with, +) from esphome.schema_extractors import ( SCHEMA_EXTRACT, schema_extractor, @@ -581,9 +587,9 @@ def boolean(value): return value if isinstance(value, str): value = value.lower() - if value in ("true", "yes", "on", "enable"): + if value in TRUTHY_BOOL_STRINGS: return True - if value in ("false", "no", "off", "disable"): + if value in FALSY_BOOL_STRINGS: return False raise Invalid( f"Expected boolean value, but cannot convert {value} to a boolean. Please use 'true' or 'false'" diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index c2e1e00830..239d874dbd 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -12,9 +12,13 @@ import re import shutil from typing import Any, NoReturn -import platformdirs - -from esphome.core import CORE, Version +from esphome.build_helpers.ccache import ( + ccache_defaults_env, + parse_enable_env, + resolve_ccache_path, +) +from esphome.build_helpers.tools_cache import IDF_TOOLS_CACHE, tools_cache_path +from esphome.core import Version from esphome.framework_helpers import ( PathType, create_venv, @@ -29,8 +33,9 @@ from esphome.framework_helpers import ( run_command, run_command_ok, str_to_lst_of_str, + tool_version_runs, ) -from esphome.helpers import get_bool_env, get_str_env, write_file_if_changed +from esphome.helpers import write_file_if_changed _LOGGER = logging.getLogger(__name__) @@ -91,22 +96,10 @@ def get_idf_tools_path() -> Path: Returns: Path object pointing to the ESP-IDF tools directory """ - # Treat an empty/whitespace ESPHOME_ESP_IDF_PREFIX as unset: Path("") - # resolves to the CWD, which would install into (and let clean-all delete) - # the working directory by accident. - if prefix := get_str_env("ESPHOME_ESP_IDF_PREFIX", "").strip(): - path = Path(prefix).expanduser() - else: - # Machine-global so all projects share the multi-GB install instead of - # a per-config-directory copy. The user cache dir (not ~/.esphome) - # avoids colliding with data_dir when configs live in the home dir. - # appauthor=False drops the redundant \ segment on Windows - # (which otherwise repeats "esphome\esphome\") to keep the path short. - path = Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / "idf" - # Resolve so an unnormalized config path (e.g. compiling ``../config/x.yaml``) - # doesn't leave ``..`` segments in the IDF_TOOLS_PATH handed to idf.py, which - # otherwise warns that the venv interpreter path doesn't match the install. - return path.resolve() + # Machine-global so all projects share the multi-GB install instead of + # a per-config-directory copy; see build_helpers.tools_cache.tools_cache_path + # for the env-override and normalization rules. + return tools_cache_path(*IDF_TOOLS_CACHE) # Windows' default MAX_PATH is 260 characters. ESP-IDF toolchains nest deeply @@ -1190,8 +1183,10 @@ def check_esp_idf_install( def _ccache_env() -> dict[str, str]: """Return ccache settings for ESP-IDF compiles. - Enabled by default whenever the ``ccache`` binary is on PATH; set - ``IDF_CCACHE_ENABLE=0`` in the environment to opt out. The cache lives under + Enabled by default whenever a runnable ``ccache`` binary is on PATH. + ``IDF_CCACHE_ENABLE=0`` opts out and ``=1`` forces it on; when that knob + is unset the shared ``ESPHOME_CCACHE_ENABLE`` applies (same 0/1 forms, + unrecognized values warn and count as unset). The cache lives under the IDF tools path (the machine-global cache dir, or ``ESPHOME_ESP_IDF_PREFIX``), so it is shared across all projects and removed by ``esphome clean-all`` along with the framework. @@ -1206,33 +1201,44 @@ def _ccache_env() -> dict[str, str]: Only values the user has not already set in the environment are returned, so a custom ``CCACHE_DIR`` / ``CCACHE_MAXSIZE`` / etc. is respected. """ - # Honor an explicit choice already in the environment (opt-out or opt-in). - if "IDF_CCACHE_ENABLE" in os.environ: - if not get_bool_env("IDF_CCACHE_ENABLE"): - return {} - elif shutil.which("ccache") is None: - # ESP-IDF silently skips ccache without the binary; don't enable it. - return {} + # IDF_CCACHE_ENABLE (the backend-native knob) wins over the shared + # ESPHOME_CCACHE_ENABLE. + idf_knob = parse_enable_env("IDF_CCACHE_ENABLE") + if idf_knob is False: + # The raw value (e.g. "disable") is still inherited by idf.py via + # os.environ, where a non-false-constant string reads as truthy; + # export the canonical off spelling instead + return {"IDF_CCACHE_ENABLE": "0"} + if idf_knob is True: + # Forced on ignores the runnability verdict, but the outcome is + # worth saying out loud. Probed directly (not via the resolver, + # whose failure message says "compiling without ccache" -- exactly + # what forced-on does NOT do): only the truly-missing case means + # idf.py compiles without ccache; a broken binary is still used, + # since idf.py does its own PATH lookup. + if (ccache := shutil.which("ccache")) is None: + _LOGGER.warning( + "IDF_CCACHE_ENABLE=1 but no ccache binary is on PATH; " + "idf.py will compile without ccache" + ) + else: + # The probe warns with this message iff the binary fails + tool_version_runs( + ccache, + "IDF_CCACHE_ENABLE=1 forces on the ccache at %s even though " + "it failed to run; idf.py will use it anyway", + ) + elif resolve_ccache_path() is None: + # ESP-IDF silently skips ccache without the binary; export the + # canonical off spelling so an unparsable inherited value (or a + # probe-rejected ccache idf.py would still find) cannot enable it + return {"IDF_CCACHE_ENABLE": "0"} - # ccache is enabled past here. build_path is set during preload for every - # config-loading command, so it being unset means a caller built the IDF env - # too early -- fail loudly rather than silently drop CCACHE_BASEDIR (which - # would quietly cost cross-device cache hits). - if CORE.build_path is None: - raise ValueError( - "CORE.build_path must be set before constructing the ESP-IDF build " - "environment" - ) - - defaults = { - "IDF_CCACHE_ENABLE": "1", - "CCACHE_DIR": str(get_idf_tools_path() / "ccache"), - "CCACHE_NOHASHDIR": "true", - "CCACHE_DEPEND": "1", - "CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()), - } - # Don't override CCACHE_* values the user already set in their environment. - return {k: v for k, v in defaults.items() if k not in os.environ} + env = ccache_defaults_env(get_idf_tools_path() / "ccache") + # Exactly one canonical spelling ever reaches idf.py, whatever the + # accepted input spelling was ("enable", "yes", ...) + env["IDF_CCACHE_ENABLE"] = "1" + return env def get_framework_env( diff --git a/esphome/framework_helpers.py b/esphome/framework_helpers.py index 031db85a65..aab7acc0e8 100644 --- a/esphome/framework_helpers.py +++ b/esphome/framework_helpers.py @@ -204,6 +204,30 @@ def run_command( return False, None, None +def tool_version_runs(binary: str, warning: str) -> bool: + """Probe ``binary --version``; on failure warn with ``warning`` % binary. + + ``shutil.which`` proves existence, not runnability (Windows .bat/.cmd + shims, stale package-manager shims). + """ + try: + subprocess.run( + [binary, "--version"], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=15, + # Repo-wide convention (posix_spawn fast path) + close_fds=False, + ) + except (OSError, subprocess.SubprocessError) as err: + # The cause (permission denied, missing DLL, timeout) is the one + # detail the user needs to fix it + _LOGGER.warning("%s (%s)", warning % binary, err) + return False + return True + + def run_command_ok(*args, **kwargs) -> bool: """ Execute a command and return only the success status. @@ -1284,3 +1308,37 @@ def download_from_mirrors( f"No mirror URL template matched the provided substitutions:{details}" ) raise ValueError("download_from_mirrors called with an empty mirrors list") + + +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. + + Also applied to the ccache path exported by the ccache helpers, which + ``shutil.which`` can return with the same prefix. + + 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 diff --git a/esphome/helpers.py b/esphome/helpers.py index d30e9b16a2..4397111c2e 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -31,7 +31,8 @@ SockAddr = IPv4SockAddr | IPv6SockAddr _LOGGER = logging.getLogger(__name__) -# cv.boolean's closed spelling tables, shared with the env-knob parsing below +# cv.boolean's closed spelling tables, shared with the strict env-knob +# parser (build_helpers.ccache.parse_enable_env) TRUTHY_BOOL_STRINGS = frozenset({"true", "yes", "on", "enable"}) FALSY_BOOL_STRINGS = frozenset({"false", "no", "off", "disable"}) # cv.boolean's spelling tables plus the 1/0 env convention diff --git a/esphome/platformio/registry.py b/esphome/platformio/registry.py new file mode 100644 index 0000000000..9538a28ff4 --- /dev/null +++ b/esphome/platformio/registry.py @@ -0,0 +1,311 @@ +"""Install packages from the PlatformIO registry without importing the +platformio package (identical bits, esphome's own download machinery).""" + +from __future__ import annotations + +from collections.abc import Callable, Collection +from functools import cache, partial +import json +import logging +import os +from pathlib import Path +import platform +from typing import NamedTuple + +from esphome.core import EsphomeError +from esphome.framework_helpers import ( + archive_extract_all, + download_from_mirrors, + download_with_resume, + rmdir, + run_batch_downloads, +) +from esphome.net_retry import fetch_with_retry, http_request + +_LOGGER = logging.getLogger(__name__) + +_REGISTRY_URL = ( + "https://api.registry.platformio.org/v3/packages/platformio/tool/{package}" +) + + +def get_systype() -> str: + """The registry system tag for the current host. + + Transliterates ``platformio.util.get_systype()`` (same + ``PLATFORMIO_SYSTEM_TYPE`` override). Deviation: windows-arm64 maps to + ``windows_amd64`` (no arm64 toolchains; x86 emulation). + """ + if systype := os.environ.get("PLATFORMIO_SYSTEM_TYPE"): + return systype + system = platform.system().lower() + arch = platform.machine().lower() + if system == "windows": + if not arch: # same fallback as upstream (platformio issue #4353) + arch = "x86_" + platform.architecture()[0] + if "x86" in arch: + arch = "amd64" if "64" in arch else "x86" + elif arch == "arm64": + arch = "amd64" + if arch == "aarch64" and platform.architecture()[0] == "32bit": + # 64-bit kernel with a 32-bit userland (e.g. 32-bit Raspberry Pi OS) + arch = "armv7l" + return f"{system}_{arch}" if arch else system + + +@cache +def registry_download(package: str, version: str) -> tuple[str, str, int | None]: + """Resolve a package's download URL, sha256, and size via the registry. + + The metadata fetch goes through ``http_request``/``fetch_with_retry`` + (the consolidated HTTP path) so it shares the Happy Eyeballs patch and + transient-retry policy of every other small fetch. Cached per process + so the prefetch and the install resolve each package once (failures + are not cached; the install retries them). + """ + url = _REGISTRY_URL.format(package=package) + + def _fetch() -> str: + resp = http_request("GET", url, timeout=30) + resp.raise_for_status() + return resp.text + + import requests + + try: + body = fetch_with_retry(url, _fetch, what="Registry lookup") + except requests.exceptions.RequestException as err: + raise EsphomeError( + f"Could not fetch registry metadata for {package}: {err}" + ) from err + try: + data = json.loads(body) + except ValueError as err: + raise EsphomeError( + f"The package registry returned invalid JSON for {package}: {err}" + ) from err + if not isinstance(data, dict): + raise EsphomeError( + f"Unexpected package registry response for {package}: {str(data)[:200]}" + ) + systype = get_systype() + versions = data.get("versions") + if not isinstance(versions, list): + # A schema change or an error/captive-portal payload must not be + # reported as "version not found" + raise EsphomeError( + f"Unexpected package registry response for {package}: {str(data)[:200]}" + ) + for ver in versions: + if not isinstance(ver, dict): + raise EsphomeError( + f"Unexpected package registry response for {package}: {str(data)[:200]}" + ) + if ver.get("name") != version: + continue + files = ver.get("files") + if not isinstance(files, list): + raise EsphomeError( + f"Unexpected package registry response for {package}: {str(ver)[:200]}" + ) + for file in files: + if not isinstance(file, dict): + raise EsphomeError( + f"Unexpected package registry response for {package}: " + f"{str(ver)[:200]}" + ) + # Only a missing key means "any system"; an empty list must not + # match, and a bare string would make ``in`` a substring test. + systems = file.get("system") + if systems is None: + systems = ["*"] + elif isinstance(systems, str): + systems = [systems] + elif not isinstance(systems, list): + # An int would make ``in`` a TypeError and a dict a key test + raise EsphomeError( + f"Unexpected package registry response for {package}: " + f"{str(file)[:200]}" + ) + if "*" in systems or systype in systems: + sha256 = (file.get("checksum") or {}).get("sha256") + if not sha256: + # Never extract an unverified archive; the registry + # publishes a checksum for every package file. + raise EsphomeError( + f"The package registry returned no sha256 for " + f"{package} {version}; refusing the unverified download" + ) + url = file.get("download_url") + if not url: + raise EsphomeError( + f"The package registry returned no download URL for " + f"{package} {version}" + ) + return (url, sha256, file.get("size")) + raise EsphomeError( + f"No {package} {version} build for this platform ({systype})" + ) + raise EsphomeError(f"{package} {version} not found in the package registry") + + +def _check_layout(name: str, dest: Path, expect: Collection[str]) -> None: + """Raise when an install tree is missing an expected directory (runs on + fresh extracts and on marker hits).""" + for rel in expect: + if not (dest / rel).is_dir(): + raise EsphomeError( + f"{name} at {dest} is missing the expected {rel} " + "directory; run 'esphome clean-all' and retry" + ) + + +class _PendingArchive(NamedTuple): + name: str + version: str + dest: Path + url: str + sha256: str + size: int + + +def _already_installed(dest: Path) -> bool: + """Whether ``dest`` holds a completed install (extraction marker).""" + return (dest / ".esphome_extracted").is_file() + + +def prefetch_packages( + packages: list[tuple[str, str, Path, list[str]]], downloads_dir: Path +) -> None: + """Download pending package archives in parallel under one combined bar. + + ``packages`` holds ``(name, version, dest, mirrors)`` per package. Purely + an optimization: ``install_package`` verifies every archive and + re-downloads anything this pass left unfinished. Mirror overrides and + registry entries without a size stay on the sequential path so its + per-file bars remain trustworthy. Each fetch holds the same per-dest + lock as ``install_package``: the archive's ``.part`` file is shared, and + two concurrent writers would truncate each other's bytes. + """ + from filelock import FileLock + + pending: list[_PendingArchive] = [] + seen: set[str] = set() + for name, version, dest, mirrors in packages: + if mirrors or (dest / ".esphome_extracted").is_file(): + continue + archive_name = f"{name}-{version}" + if archive_name in seen: + # A duplicate entry would race itself between two workers + continue + seen.add(archive_name) + try: + url, sha256, size = registry_download(name, version) + except EsphomeError as err: + # The sequential install reports the real failure with context + _LOGGER.debug("Prefetch resolve for %s failed: %s", name, err) + continue + if not size: + continue + archive = downloads_dir / archive_name + if archive.is_file() and archive.stat().st_size == size: + continue + pending.append(_PendingArchive(name, version, dest, url, sha256, size)) + if len(pending) < 2: + return + downloads_dir.mkdir(parents=True, exist_ok=True) + _LOGGER.info( + "Downloading %d package archive(s): %s", + len(pending), + ", ".join(entry.name for entry in pending), + ) + + def _fetch(entry: _PendingArchive, tracker: Callable[[int], None]) -> None: + entry.dest.parent.mkdir(parents=True, exist_ok=True) + with FileLock(f"{entry.dest}.lock", fallback_to_soft=False): + # Marker re-check: a concurrent build may have installed (and + # deleted the archive of) this package while we waited; + # re-downloading would orphan a fresh copy in downloads_dir + # no branch: the thread tracer misses the skip edge; both + # arms of _already_installed are pinned directly + if not _already_installed(entry.dest): # pragma: no branch + download_with_resume( + entry.url, + downloads_dir / f"{entry.name}-{entry.version}", + sha256=entry.sha256, + size=entry.size, + progress=tracker, + ) + + failures = run_batch_downloads( + "Downloading packages", + [(entry.name, entry.size, partial(_fetch, entry)) for entry in pending], + ) + for name, err in failures: + if isinstance(err, (EsphomeError, OSError)): + # Expected download failures: install_package retries this one + # itself, with a visible bar + _LOGGER.debug("Prefetch of %s failed: %s", name, err) + else: + # Anything else is a programming error that would otherwise + # become a permanent silent no-op + _LOGGER.warning("Prefetch of %s failed: %r", name, err, exc_info=err) + + +def install_package( + name: str, + version: str, + dest: Path, + mirrors: list[str], + downloads_dir: Path, + expect: Collection[str], +) -> None: + """Download, verify, and extract one package if not already installed. + + The registry path is integrity-checked against the sha256 the registry + publishes; a mirror override (URL templates with ``{VERSION}``/``{SYSTEM}`` + substitution) is trusted as configured. ``downloads_dir`` holds the + archive between runs so an interrupted download resumes. + """ + if not expect: + # Layout validation before marker.touch() is the only guard against + # caching a truncated mirror archive as a good install + raise ValueError("install_package requires a non-empty expect") + marker = dest / ".esphome_extracted" + if marker.is_file(): + _check_layout(name, dest, expect) + return + from filelock import FileLock + + # Serialize concurrent cold builds (same filelock pattern as git.py). + dest.parent.mkdir(parents=True, exist_ok=True) + # A soft-lock fallback would turn a hard-killed run into a permanent + # hang (see git.py). + with FileLock(f"{dest}.lock", fallback_to_soft=False): + if marker.is_file(): + # Another process finished the install while we waited + return + rmdir(dest, msg=f"Clean up incomplete {name} install") + # Persistent location so an interrupted download resumes across runs. + downloads_dir.mkdir(parents=True, exist_ok=True) + archive = downloads_dir / f"{name}-{version}" + _LOGGER.info("Downloading %s %s ...", name, version) + if mirrors: + _LOGGER.warning( + "Downloading %s from a mirror override; checksum verification " + "is skipped for mirrors", + name, + ) + download_from_mirrors( + mirrors, {"VERSION": version, "SYSTEM": get_systype()}, archive + ) + else: + url, sha256, size = registry_download(name, version) + download_with_resume(url, archive, sha256=sha256, size=size) + _LOGGER.info("Extracting %s ...", name) + archive_extract_all(archive, dest, progress_header="Extracting") + # Validate the layout before recording success, so an unexpected + # package is never cached as a working install. + _check_layout(name, dest, expect) + marker.touch() + archive.unlink(missing_ok=True) diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index a98ef3e9fe..cf2094dfe0 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -4,19 +4,18 @@ import logging import os from pathlib import Path import re -import shutil -import subprocess import sys from typing import TYPE_CHECKING, Any import platformdirs +from esphome.build_helpers.ccache import resolve_ccache_path from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE from esphome.core import CORE, EsphomeError +from esphome.framework_helpers import strip_win_long_path_prefix from esphome.helpers import ( add_git_ceiling_directory, copy_file_if_changed, - get_bool_env, rmtree, write_file, ) @@ -41,40 +40,6 @@ _PIO_PYTHON_STAMP_LOCK = ".esphome.pio.stamp.lock" _PIO_PYTHON_STAMP_SCHEMA = "0" -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. - - Also applied to the ccache path exported by ``_ccache_env()``, which - ``shutil.which`` can return with the same prefix. - - No-op on non-Windows platforms. - """ - if sys.platform != "win32": - return path - if path.startswith("\\\\?\\UNC\\"): - # \\?\UNC\server\share\... -> \\server\share\... - return "\\\\" + path[len("\\\\?\\UNC\\") :] - if path.startswith("\\\\?\\"): - return path[len("\\\\?\\") :] - return path - - def get_platformio_config() -> "ProjectConfig | None": """Return PlatformIO's ``ProjectConfig``, or None when PlatformIO is absent.""" try: @@ -238,35 +203,6 @@ def _check_platformio_python_stamp(config: "ProjectConfig") -> None: _write_pio_stamp_python(stamp_file, current) -def _ccache_runs(ccache: str) -> bool: - """Return True when the ``ccache`` found on PATH actually runs. - - ``shutil.which`` proves existence, not runnability: on Windows it also - matches ``.bat``/``.cmd`` wrappers and stale package-manager shims whose - target is gone. Wrapping compiles around such a find fails every compile - step with an opaque OS error, so probe once and fall back to compiling - without ccache when the probe fails. - """ - try: - subprocess.run( - [ccache, "--version"], - check=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - timeout=15, - # Repo-wide convention (posix_spawn fast path); see the - # close_fds=False call sites across esphome/ and script/helpers.py - close_fds=False, - ) - except (OSError, subprocess.SubprocessError): - _LOGGER.warning( - "Ignoring ccache at %s because it failed to run; compiling without ccache", - ccache, - ) - return False - return True - - def _ccache_env() -> dict[str, str]: r"""Return ccache settings for PlatformIO builds. @@ -285,7 +221,7 @@ def _ccache_env() -> dict[str, str]: runs fine through ``CreateProcess``, which is how ESP-IDF invokes it, but SCons runs every compile through ``cmd.exe``, which fails on it with "The system cannot find the path specified." (#18399), so the prefix is - stripped here with ``_strip_win_long_path_prefix()`` before the + stripped here with ``strip_win_long_path_prefix()`` before the runnability probe, which therefore validates the exact string the build will execute. ``ESPHOME_CCACHE_PATH`` is an internal channel, not a user setting: the @@ -311,22 +247,8 @@ def _ccache_env() -> dict[str, str]: build dir. The other ``CCACHE_*`` values the user already set in the environment are respected. """ - explicit = "ESPHOME_CCACHE_ENABLE" in os.environ - if explicit and not get_bool_env("ESPHOME_CCACHE_ENABLE"): - return {"ESPHOME_CCACHE_ENABLE": "0"} - ccache_path = shutil.which("ccache") + ccache_path = resolve_ccache_path() if ccache_path is None: - if explicit: - _LOGGER.warning( - "ESPHOME_CCACHE_ENABLE is set but no ccache binary is on PATH; " - "compiling without ccache" - ) - return {"ESPHOME_CCACHE_ENABLE": "0"} - # Strip before probing so the probe validates (and the failure warning - # names) the exact string the build will execute through cmd.exe. - ccache_path = _strip_win_long_path_prefix(ccache_path) - # An explicit opt-in skips the runnability probe. - if not explicit and not _ccache_runs(ccache_path): return {"ESPHOME_CCACHE_ENABLE": "0"} env = { "ESPHOME_CCACHE_ENABLE": "1", @@ -388,7 +310,7 @@ def run_platformio_cli(*args, **kwargs) -> str | int: # 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) + 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 diff --git a/esphome/writer.py b/esphome/writer.py index 85c0642774..0b9e7669ef 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -706,14 +706,17 @@ def clean_all(configuration: list[str]): # the per-config loop above can't reach. Wipe the default cache root # (also catches leftovers from older install layouts), then the resolved # install paths for the ESPHOME_*_PREFIX overrides (docker/add-on/CI) - # that live outside it. + # that live outside it. Every backend's cache is listed in + # TOOLS_CACHE_SPECS, so registering one there is the only step. import platformdirs - from esphome.components.nrf52.framework import get_sdk_nrf_tools_path - from esphome.espidf.framework import get_idf_tools_path + from esphome.build_helpers.tools_cache import TOOLS_CACHE_SPECS, tools_cache_path cache_root = Path(platformdirs.user_cache_dir("esphome", appauthor=False)).resolve() - for install_path in (cache_root, get_idf_tools_path(), get_sdk_nrf_tools_path()): + install_paths = [cache_root] + [ + tools_cache_path(*spec) for spec in TOOLS_CACHE_SPECS + ] + for install_path in install_paths: if install_path.is_dir(): _LOGGER.info("Deleting %s", install_path) rmtree(install_path) diff --git a/requirements.txt b/requirements.txt index 4f6bdbad4c..3d4439bf10 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,6 +28,7 @@ smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 platformdirs==4.11.3 # native esp-idf toolchain global cache dir +ninja==1.13.0 # native esp8266 arduino toolchain build driver filelock==3.32.3 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this diff --git a/tests/unit_tests/build_helpers/test_ccache.py b/tests/unit_tests/build_helpers/test_ccache.py new file mode 100644 index 0000000000..0237db4081 --- /dev/null +++ b/tests/unit_tests/build_helpers/test_ccache.py @@ -0,0 +1,122 @@ +"""Tests for the shared ccache policy in esphome.build_helpers.ccache.""" + +from __future__ import annotations + +import os +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from esphome.build_helpers import ccache + + +def test_resolve_opt_out() -> None: + with patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "0"}): + assert ccache.resolve_ccache_path() is None + + +def test_resolve_no_binary(caplog: pytest.LogCaptureFixture) -> None: + with ( + patch.dict(os.environ, {}, clear=True), + patch("shutil.which", return_value=None), + ): + assert ccache.resolve_ccache_path() is None + assert "no ccache binary" not in caplog.text + + +def test_resolve_probe_failure() -> None: + with ( + patch.dict(os.environ, {}, clear=True), + patch("shutil.which", return_value="/usr/bin/ccache"), + patch("esphome.framework_helpers.subprocess.run", side_effect=OSError("boom")), + ): + assert ccache.resolve_ccache_path() is None + + +def test_resolve_explicit_skips_probe_and_warns_missing( + caplog: pytest.LogCaptureFixture, +) -> None: + with ( + patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "1"}, clear=True), + patch("shutil.which", return_value="/usr/bin/ccache"), + patch.object(ccache, "_ccache_runs", side_effect=AssertionError), + ): + assert ccache.resolve_ccache_path() == "/usr/bin/ccache" + with ( + patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "1"}, clear=True), + patch("shutil.which", return_value=None), + ): + assert ccache.resolve_ccache_path() is None + assert "no ccache binary is on PATH" in caplog.text + + +def test_probe_spawns_with_close_fds_false() -> None: + with patch("esphome.framework_helpers.subprocess.run") as mock_run: + assert ccache._ccache_runs("/usr/bin/ccache") is True + assert mock_run.call_args.kwargs["close_fds"] is False + + +def test_defaults_env(tmp_path: Path) -> None: + with ( + patch("esphome.core.CORE", SimpleNamespace(build_path=tmp_path / "b")), + patch.dict(os.environ, {"CCACHE_NOHASHDIR": "false"}, clear=True), + ): + env = ccache.ccache_defaults_env(tmp_path / "cache") + assert env["CCACHE_DIR"] == str(tmp_path / "cache") + assert env["CCACHE_DEPEND"] == "1" + assert "CCACHE_NOHASHDIR" not in env # user value respected + + +def test_defaults_env_requires_build_path() -> None: + with ( + patch("esphome.core.CORE", SimpleNamespace(build_path=None)), + pytest.raises(ValueError, match="build_path"), + ): + ccache.ccache_defaults_env(Path("/x")) + + +@pytest.mark.parametrize("value", ["no", "off", "false", "0"]) +def test_resolve_opt_out_synonyms(value: str) -> None: + """Every recognized falsy spelling disables ccache.""" + with patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": value}): + assert ccache.resolve_ccache_path() is None + + +def test_resolve_unrecognized_value_warns_and_probes( + caplog: pytest.LogCaptureFixture, +) -> None: + """An unparsable ESPHOME_CCACHE_ENABLE is treated as unset: it must not + silently enable ccache or skip the runnability probe.""" + with ( + patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "enabled"}), + patch("shutil.which", return_value="/usr/bin/ccache"), + patch.object(ccache, "_ccache_runs", return_value=False) as mock_probe, + ): + assert ccache.resolve_ccache_path() is None + mock_probe.assert_called_once() + assert "unrecognized ESPHOME_CCACHE_ENABLE" in caplog.text + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("1", True), + ("enable", True), + ("ON", True), + ("0", False), + ("disable", False), + ("Off", False), + ("maybe", None), + # ENV KNOB= (Docker/CI) has always read as a disable + ("", False), + (" ", False), + ], +) +def test_parse_enable_env_spelling_tables( + monkeypatch: pytest.MonkeyPatch, raw: str, expected: bool | None +) -> None: + """cv.boolean's spelling tables plus the 1/0 env convention.""" + monkeypatch.setenv("ESPHOME_CCACHE_ENABLE", raw) + assert ccache.parse_enable_env("ESPHOME_CCACHE_ENABLE") is expected diff --git a/tests/unit_tests/build_helpers/test_ninja.py b/tests/unit_tests/build_helpers/test_ninja.py new file mode 100644 index 0000000000..6f0bbda0b9 --- /dev/null +++ b/tests/unit_tests/build_helpers/test_ninja.py @@ -0,0 +1,143 @@ +"""Tests for esphome.build_helpers.ninja.""" + +from __future__ import annotations + +import os +from pathlib import Path +import subprocess +import sys +from unittest.mock import MagicMock, patch + +import pytest + +from esphome.build_helpers import ninja as ninja_helper +from esphome.core import EsphomeError + + +def test_find_ninja_prefers_path(tmp_path: Path) -> None: + with ( + patch("shutil.which", return_value=str(tmp_path / "ninja")), + patch.object(ninja_helper, "_ninja_runs", return_value=True), + ): + assert ninja_helper.find_ninja() == tmp_path / "ninja" + + +def test_find_ninja_falls_back_to_wheel(tmp_path: Path) -> None: + """Without a PATH entry, the ninja PyPI wheel's binary is used.""" + binary_name = "ninja.exe" if os.name == "nt" else "ninja" + (tmp_path / binary_name).touch() + wheel = MagicMock(BIN_DIR=str(tmp_path)) + with ( + patch("shutil.which", return_value=None), + patch.dict(sys.modules, {"ninja": wheel}), + ): + assert ninja_helper.find_ninja() == tmp_path / binary_name + + +def test_find_ninja_package_not_installed() -> None: + """A missing ninja package raises the actionable message, not ImportError.""" + with ( + patch("shutil.which", return_value=None), + patch.dict(sys.modules, {"ninja": None}), + pytest.raises(EsphomeError, match="ninja not found"), + ): + ninja_helper.find_ninja() + + +def test_find_ninja_missing_everywhere(tmp_path: Path) -> None: + wheel = MagicMock(BIN_DIR=str(tmp_path)) + with ( + patch("shutil.which", return_value=None), + patch.dict(sys.modules, {"ninja": wheel}), + pytest.raises(EsphomeError, match="ninja not found"), + ): + ninja_helper.find_ninja() + + +def test_escape_ninja_specials() -> None: + assert ninja_helper.escape("a b:c$d") == "a$ b$:c$$d" + + +def _q(tok: str) -> str: + """The platform's shell_token quote wrapper (argv rule on Windows).""" + return f'"{tok}"' if os.name == "nt" else f"'{tok}'" + + +def test_quote_arg_windows_argv_rule() -> None: + # Backslash runs double only before a quote (subprocess.list2cmdline rule) + assert ninja_helper.quote_arg('-DX=a\\"b c') == '"-DX=a\\\\\\"b c"' + assert ninja_helper.quote_arg("a b\\") == '"a b\\\\"' + + +def test_shell_token_quotes_only_when_needed() -> None: + assert ninja_helper.shell_token("-Os") == "-Os" + assert ninja_helper.shell_token("-DP=C:\\x y") == _q("-DP=C:\\x y") + assert ninja_helper.shell_token("plain", force=True) == _q("plain") + + +def test_shell_token_quotes_shell_metacharacters() -> None: + """Tokens like -DMASK=(1<<3) must not reach /bin/sh -c bare.""" + assert ninja_helper.shell_token("-DMASK=(1<<3)") == _q("-DMASK=(1<<3)") + assert ninja_helper.shell_token("-DX=a;b") == _q("-DX=a;b") + assert ninja_helper.shell_token("-DX=$HOME") == _q("-DX=$$HOME") + + +def test_shell_token_posix_roundtrips_through_sh() -> None: + """Backslash runs, $, backticks, and quotes must reach the compiler + exactly as lexed once ninja un-doubles $$ and /bin/sh strips quotes.""" + + if sys.platform == "win32": + pytest.skip("POSIX sh quoting") + for tok in ("-DP=a\\\\b", "-DX=$VAR", "-DY=`date`", "-DZ=it's", '-DC="q"'): + quoted = ninja_helper.shell_token(tok).replace("$$", "$") + out = subprocess.run( + ["/bin/sh", "-c", f'printf "%s" {quoted}'], + capture_output=True, + text=True, + check=True, + ) + assert out.stdout == tok + + +def test_quote_path_force_quotes() -> None: + assert ninja_helper.quote_path(Path("a b")) == _q("a b") + assert ninja_helper.quote_path("simple") == _q("simple") + + +def test_shell_token_empty_token_is_quoted() -> None: + """An empty argv element must survive as an explicit pair of quotes.""" + assert ninja_helper.shell_token("") == _q("") + + +def test_find_ninja_probes_path_hit(tmp_path: Path) -> None: + """A broken PATH shim falls back to the wheel instead of failing every + build later.""" + binary_name = "ninja.exe" if os.name == "nt" else "ninja" + (tmp_path / binary_name).touch() + wheel = MagicMock(BIN_DIR=str(tmp_path)) + with ( + patch("shutil.which", return_value="/broken/ninja"), + patch.object(ninja_helper, "_ninja_runs", return_value=False), + patch.dict(sys.modules, {"ninja": wheel}), + ): + assert ninja_helper.find_ninja() == tmp_path / binary_name + + +def test_ninja_probe_failure_warns(caplog: pytest.LogCaptureFixture) -> None: + with patch("esphome.framework_helpers.subprocess.run", side_effect=OSError("boom")): + assert ninja_helper._ninja_runs("/broken/ninja") is False + assert "failed to run" in caplog.text + + +def test_ninja_probe_success() -> None: + with patch("esphome.framework_helpers.subprocess.run") as mock_run: + assert ninja_helper._ninja_runs("/usr/bin/ninja") is True + assert mock_run.call_args.kwargs["close_fds"] is False + + +def test_shell_token_windows_branch_uses_argv_rule() -> None: + """The nt branch quotes with the CreateProcess argv rule (the ubuntu + coverage run never takes it naturally).""" + with patch.object(os, "name", "nt"): + assert ninja_helper.shell_token("a b") == '"a b"' + assert ninja_helper.shell_token("", force=True) == '""' diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index 1bef743f4c..45a971ca01 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -1560,13 +1560,14 @@ def test_get_framework_env_without_python_env_uses_os_path(tmp_path: Path) -> No def _ccache_patches(tmp_path: Path, which: str | None, build_path: Path | None): return ( - patch("esphome.espidf.framework.shutil.which", return_value=which), + patch("esphome.espidf.framework.resolve_ccache_path", return_value=which), patch( "esphome.espidf.framework.get_idf_tools_path", return_value=tmp_path / "tools", ), + # ccache_defaults_env (build_helpers.ccache) reads CORE at call time patch( - "esphome.espidf.framework.CORE", + "esphome.core.CORE", SimpleNamespace(build_path=build_path), ), ) @@ -1587,7 +1588,8 @@ def test_ccache_env_disabled_when_binary_missing(tmp_path: Path) -> None: # build_path is None here too: a disabled cache must not require it. p1, p2, p3 = _ccache_patches(tmp_path, None, None) with patch.dict("os.environ", {}, clear=True), p1, p2, p3: - assert _ccache_env() == {} + # Canonical off, so an inherited/unparsable value cannot enable it + assert _ccache_env() == {"IDF_CCACHE_ENABLE": "0"} def test_ccache_env_opt_out_via_env(tmp_path: Path) -> None: @@ -1595,18 +1597,111 @@ def test_ccache_env_opt_out_via_env(tmp_path: Path) -> None: # short-circuits before build_path is needed. p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", None) with patch.dict("os.environ", {"IDF_CCACHE_ENABLE": "0"}, clear=True), p1, p2, p3: - assert _ccache_env() == {} + # The canonical off spelling is exported: the raw value is inherited + # by idf.py, where a spelling like "disable" would read as truthy + assert _ccache_env() == {"IDF_CCACHE_ENABLE": "0"} -def test_ccache_env_opt_in_without_binary(tmp_path: Path) -> None: - # Explicit IDF_CCACHE_ENABLE=1 forces it on without probing PATH. It's - # already in the environment, so it isn't re-emitted, but the rest is. +def test_ccache_env_opt_in_without_binary( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + # Explicit IDF_CCACHE_ENABLE=1 forces it on; without a usable binary + # idf.py silently skips ccache, so this branch must say so out loud. p1, p2, p3 = _ccache_patches(tmp_path, None, tmp_path / "build") with patch.dict("os.environ", {"IDF_CCACHE_ENABLE": "1"}, clear=True), p1, p2, p3: env = _ccache_env() - assert "IDF_CCACHE_ENABLE" not in env + assert env["IDF_CCACHE_ENABLE"] == "1" assert env["CCACHE_DIR"] == str(tmp_path / "tools" / "ccache") assert env["CCACHE_DEPEND"] == "1" + assert "no ccache binary is on PATH" in caplog.text + + +def test_ccache_env_opt_in_with_working_binary( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + # Forced on with a working binary: no warning fires at all. + ccache = tmp_path / "ccache" + ccache.touch() + p1, p2, p3 = _ccache_patches(tmp_path, str(ccache), tmp_path / "build") + with ( + patch.dict("os.environ", {"IDF_CCACHE_ENABLE": "1"}, clear=True), + patch("esphome.espidf.framework.shutil.which", return_value=str(ccache)), + patch("esphome.espidf.framework.tool_version_runs", return_value=True), + p1, + p2, + p3, + ): + env = _ccache_env() + assert env["IDF_CCACHE_ENABLE"] == "1" + assert not [r for r in caplog.records if r.levelno >= logging.WARNING] + + +def test_ccache_env_opt_in_with_rejected_binary( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + # Forced on with a present-but-rejected binary: idf.py does its own + # PATH lookup and uses it anyway; the warning must say so, not claim + # the build runs without ccache. + # A present but non-executable file: the real probe fails and logs + # the forced-on message (patching the probe would silence it) + broken = tmp_path / "broken-ccache" + broken.touch() + p1, p2, p3 = _ccache_patches(tmp_path, None, tmp_path / "build") + with ( + patch.dict("os.environ", {"IDF_CCACHE_ENABLE": "1"}, clear=True), + patch("esphome.espidf.framework.shutil.which", return_value=str(broken)), + p1, + p2, + p3, + ): + env = _ccache_env() + assert env["IDF_CCACHE_ENABLE"] == "1" + assert "idf.py will use it anyway" in caplog.text + # Exactly one story: the resolver's contradictory "compiling without + # ccache" must not precede it + assert "compiling without ccache" not in caplog.text + + +def test_ccache_env_honors_shared_esphome_opt_out(tmp_path: Path) -> None: + """ESPHOME_CCACHE_ENABLE=0 disables ccache here too; the shared policy + must not apply to every backend except this one.""" + _p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", tmp_path / "build") + env_vars = {"ESPHOME_CCACHE_ENABLE": "0", "PATH": "/usr/bin"} + with patch.dict("os.environ", env_vars, clear=True), p2, p3: + # The real resolver runs so the opt-out parse is exercised + assert _ccache_env() == {"IDF_CCACHE_ENABLE": "0"} + + +@pytest.mark.parametrize("value", ["off", "no"]) +def test_ccache_env_idf_knob_parses_strictly(tmp_path: Path, value: str) -> None: + """IDF_CCACHE_ENABLE uses the same strict table as the shared knob, so + "off" disables instead of reading as truthy.""" + p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", tmp_path / "build") + with patch.dict("os.environ", {"IDF_CCACHE_ENABLE": value}, clear=True), p1, p2, p3: + assert _ccache_env() == {"IDF_CCACHE_ENABLE": "0"} + + +def test_ccache_env_idf_knob_unrecognized_warns_and_defers( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """An unparsable IDF_CCACHE_ENABLE warns, defers to the shared resolver, + and is not forwarded to idf.py as truthy.""" + p1, p2, p3 = _ccache_patches(tmp_path, "/usr/bin/ccache", tmp_path / "build") + env_vars = {"IDF_CCACHE_ENABLE": "enabled"} + with patch.dict("os.environ", env_vars, clear=True), p1, p2, p3: + env = _ccache_env() + assert "unrecognized IDF_CCACHE_ENABLE" in caplog.text + assert env["IDF_CCACHE_ENABLE"] == "1" + + +def test_ccache_env_idf_knob_wins_over_shared_opt_out(tmp_path: Path) -> None: + """IDF_CCACHE_ENABLE=1 takes precedence over ESPHOME_CCACHE_ENABLE=0.""" + p1, p2, p3 = _ccache_patches(tmp_path, None, tmp_path / "build") + env_vars = {"IDF_CCACHE_ENABLE": "1", "ESPHOME_CCACHE_ENABLE": "0"} + with patch.dict("os.environ", env_vars, clear=True), p1, p2, p3: + env = _ccache_env() + assert env["CCACHE_DIR"] == str(tmp_path / "tools" / "ccache") + assert env["IDF_CCACHE_ENABLE"] == "1" def test_ccache_env_preserves_user_overrides(tmp_path: Path) -> None: diff --git a/tests/unit_tests/test_framework_helpers.py b/tests/unit_tests/test_framework_helpers.py index f001bd6c37..8844212600 100644 --- a/tests/unit_tests/test_framework_helpers.py +++ b/tests/unit_tests/test_framework_helpers.py @@ -2278,3 +2278,37 @@ class TestGetProjectCxxCompileFlags: def test_empty_flags(self) -> None: with patch("esphome.core.CORE", _make_core_cxx(set())): assert get_project_cxx_compile_flags() == [] + + +@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.framework_helpers.sys.platform", platform): + assert framework_helpers.strip_win_long_path_prefix(input_path) == expected diff --git a/tests/unit_tests/test_platformio_registry.py b/tests/unit_tests/test_platformio_registry.py new file mode 100644 index 0000000000..6ba8691c4e --- /dev/null +++ b/tests/unit_tests/test_platformio_registry.py @@ -0,0 +1,725 @@ +"""Tests for esphome.platformio.registry (PIO-registry package installs).""" + +from __future__ import annotations + +from contextlib import contextmanager +import json +import os +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from esphome.core import EsphomeError +from esphome.platformio import registry + + +def test_registry_download_resolves_once_per_process() -> None: + """The prefetch and the install share one metadata resolve per package.""" + calls: list[dict] = [] + payload = { + "versions": [ + { + "name": "1.0.0", + "files": [ + { + "download_url": "http://x/pkg.tar.gz", + "checksum": {"sha256": "ab" * 32}, + "size": 5, + } + ], + } + ] + } + + def fake_request(method, url, **kwargs): + calls.append(url) + return _http_response(json.dumps(payload)) + + with patch.object(registry, "http_request", side_effect=fake_request): + first = registry.registry_download("o/pkg", "1.0.0") + second = registry.registry_download("o/pkg", "1.0.0") + assert first == second + assert len(calls) == 1 + + +@pytest.fixture(autouse=True) +def _fresh_registry_cache(): + # registry_download memoizes per process; tests reuse package names + registry.registry_download.cache_clear() + yield + registry.registry_download.cache_clear() + + +@pytest.mark.parametrize( + ("system", "machine", "expected"), + [ + ("Darwin", "arm64", "darwin_arm64"), + ("Darwin", "x86_64", "darwin_x86_64"), + ("Windows", "AMD64", "windows_amd64"), + # Deviation from upstream: auto-mapped to the emulated-x86 packages + ("Windows", "ARM64", "windows_amd64"), + ("Windows", "x86", "windows_x86"), + ("Linux", "x86_64", "linux_x86_64"), + ("Linux", "aarch64", "linux_aarch64"), + ("Linux", "i686", "linux_i686"), + ("Linux", "armv7l", "linux_armv7l"), + # Unknown hosts pass through like upstream; the registry lookup + # then fails naming the tag + ("FreeBSD", "amd64", "freebsd_amd64"), + ], +) +def test_get_systype(system: str, machine: str, expected: str) -> None: + with ( + patch("platform.system", return_value=system), + patch("platform.machine", return_value=machine), + patch("platform.architecture", return_value=("64bit", "")), + ): + assert registry.get_systype() == expected + + +def test_get_systype_env_override() -> None: + """PLATFORMIO_SYSTEM_TYPE wins, exactly as in upstream get_systype().""" + with patch.dict(os.environ, {"PLATFORMIO_SYSTEM_TYPE": "windows_amd64"}): + assert registry.get_systype() == "windows_amd64" + + +def test_get_systype_aarch64_32bit_userland() -> None: + """A 32-bit userland on a 64-bit arm kernel gets armv7l binaries.""" + with ( + patch("platform.system", return_value="Linux"), + patch("platform.machine", return_value="aarch64"), + patch("platform.architecture", return_value=("32bit", "")), + ): + assert registry.get_systype() == "linux_armv7l" + + +def test_get_systype_windows_empty_machine() -> None: + """An empty machine string falls back to the architecture bits.""" + with ( + patch("platform.system", return_value="Windows"), + patch("platform.machine", return_value=""), + patch("platform.architecture", return_value=("64bit", "")), + ): + assert registry.get_systype() == "windows_amd64" + + +def _http_response(text: str) -> MagicMock: + resp = MagicMock() + resp.text = text + resp.raise_for_status.return_value = None + return resp + + +def _registry_response(files: list[dict]): + """Patch the consolidated HTTP path to serve a canned registry response.""" + payload = {"versions": [{"name": "1.0.0", "files": files}]} + return patch.object( + registry, "http_request", return_value=_http_response(json.dumps(payload)) + ) + + +def test_registry_download_uses_shared_http_path() -> None: + """The metadata fetch delegates to the consolidated http_request path; + request failures surface as a named EsphomeError.""" + import requests as req + + with ( + patch.object( + registry, + "http_request", + side_effect=req.exceptions.ConnectionError("registry down"), + ) as mock_request, + pytest.raises(EsphomeError, match="Could not fetch registry metadata"), + ): + registry.registry_download("pkg", "1.0.0") + (method, url), _ = mock_request.call_args + assert method == "GET" + assert url == registry._REGISTRY_URL.format(package="pkg") + + +def test_registry_download_invalid_json_is_clean() -> None: + with ( + patch.object( + registry, + "http_request", + return_value=_http_response("not json"), + ), + pytest.raises(EsphomeError, match="invalid JSON"), + ): + registry.registry_download("pkg", "1.0.0") + + +def test_registry_download_matches_system() -> None: + with ( + _registry_response( + [ + {"system": ["windows_amd64"], "download_url": "http://x/win"}, + { + "system": ["linux_x86_64"], + "download_url": "http://x/linux", + "checksum": {"sha256": "abc123"}, + "size": 42, + }, + ] + ), + patch.object(registry, "get_systype", return_value="linux_x86_64"), + ): + assert registry.registry_download("pkg", "1.0.0") == ( + "http://x/linux", + "abc123", + 42, + ) + + +def test_registry_download_bare_string_system() -> None: + """A bare-string system tag is an exact match, not a substring test.""" + with ( + _registry_response( + [ + {"system": "linux_x86", "download_url": "http://x/x86"}, + { + "system": "linux_x86_64", + "download_url": "http://x/x86_64", + "checksum": {"sha256": "abc"}, + }, + ] + ), + patch.object(registry, "get_systype", return_value="linux_x86_64"), + ): + assert registry.registry_download("pkg", "1.0.0")[0] == "http://x/x86_64" + + +def test_registry_download_wildcard_system() -> None: + with _registry_response( + [ + { + "system": "*", + "download_url": "http://x/any", + "checksum": {"sha256": "abc"}, + "size": 7, + } + ] + ): + assert registry.registry_download("pkg", "1.0.0") == ( + "http://x/any", + "abc", + 7, + ) + + +def test_registry_download_missing_checksum_raises() -> None: + """An unverifiable archive is refused, never silently extracted.""" + with ( + _registry_response([{"system": "*", "download_url": "http://x/any"}]), + pytest.raises(EsphomeError, match="no sha256"), + ): + registry.registry_download("pkg", "1.0.0") + + +def test_registry_download_no_system_match() -> None: + with ( + _registry_response( + [{"system": ["windows_amd64"], "download_url": "http://x/win"}] + ), + patch.object(registry, "get_systype", return_value="linux_x86_64"), + pytest.raises(EsphomeError, match="No pkg 1.0.0 build"), + ): + registry.registry_download("pkg", "1.0.0") + + +def test_registry_download_version_not_found() -> None: + with ( + patch.object( + registry, + "http_request", + return_value=_http_response( + json.dumps({"versions": [{"name": "2.0.0", "files": []}]}) + ), + ), + pytest.raises(EsphomeError, match="not found"), + ): + registry.registry_download("pkg", "1.0.0") + + +def test_install_package_skips_when_marker_exists(tmp_path: Path) -> None: + dest = tmp_path / "pkg" + (dest / "payload").mkdir(parents=True) + (dest / ".esphome_extracted").touch() + with patch.object(registry, "download_from_mirrors") as mock_download: + registry.install_package( + "pkg", "1.0.0", dest, [], tmp_path / "dl", expect=("payload",) + ) + mock_download.assert_not_called() + + +def test_install_package_marker_hit_rechecks_layout(tmp_path: Path) -> None: + """A marked install that later lost files fails by name instead of + surfacing as an opaque toolchain error.""" + dest = tmp_path / "pkg" + dest.mkdir() + (dest / ".esphome_extracted").touch() + with pytest.raises(EsphomeError, match="missing the expected payload"): + registry.install_package( + "pkg", "1.0.0", dest, [], tmp_path / "dl", expect=("payload",) + ) + + +def test_install_package_downloads_via_mirrors(tmp_path: Path) -> None: + dest = tmp_path / "pkg" + mirrors = ["http://mirror/{VERSION}/{SYSTEM}.tar.gz"] + with ( + patch.object(registry, "download_from_mirrors") as mock_download, + patch.object(registry, "archive_extract_all") as mock_extract, + patch.object(registry, "get_systype", return_value="linux_x86_64"), + ): + # Extraction is expected to create the directory + mock_extract.side_effect = lambda *_a, **_kw: (dest / "payload").mkdir( + parents=True + ) + registry.install_package( + "pkg", "1.0.0", dest, mirrors, tmp_path / "dl", expect=("payload",) + ) + assert mock_download.call_args[0][0] is mirrors + assert mock_download.call_args[0][1] == { + "VERSION": "1.0.0", + "SYSTEM": "linux_x86_64", + } + assert (dest / ".esphome_extracted").is_file() + + +def test_install_package_downloads_via_registry(tmp_path: Path) -> None: + """The registry path downloads with the registry's sha256 and size.""" + dest = tmp_path / "pkg" + with ( + patch.object(registry, "download_with_resume") as mock_download, + patch.object(registry, "archive_extract_all") as mock_extract, + patch.object( + registry, + "registry_download", + return_value=("http://x/pkg.tar.gz", "abc123", 42), + ), + ): + mock_extract.side_effect = lambda *_a, **_kw: (dest / "payload").mkdir( + parents=True + ) + registry.install_package( + "pkg", "1.0.0", dest, [], tmp_path / "dl", expect=("payload",) + ) + assert mock_download.call_args[0][0] == "http://x/pkg.tar.gz" + assert mock_download.call_args[1] == {"sha256": "abc123", "size": 42} + + +def test_install_package_validates_expected_layout(tmp_path: Path) -> None: + """The success marker is only written when the extracted tree is usable.""" + dest = tmp_path / "pkg" + with ( + patch.object(registry, "download_from_mirrors"), + patch.object(registry, "archive_extract_all") as mock_extract, + patch.object(registry, "get_systype", return_value="linux_x86_64"), + ): + mock_extract.side_effect = lambda *_a, **_kw: (dest / "bin").mkdir(parents=True) + registry.install_package( + "pkg", "1.0.0", dest, ["http://m"], tmp_path / "dl", expect=("bin",) + ) + assert (dest / ".esphome_extracted").is_file() + + +def test_install_package_unexpected_layout_raises(tmp_path: Path) -> None: + dest = tmp_path / "pkg" + with ( + patch.object(registry, "download_from_mirrors"), + patch.object(registry, "archive_extract_all") as mock_extract, + patch.object(registry, "get_systype", return_value="linux_x86_64"), + pytest.raises(EsphomeError, match="missing the expected bin"), + ): + mock_extract.side_effect = lambda *_a, **_kw: (dest / "payload").mkdir( + parents=True + ) + registry.install_package( + "pkg", "1.0.0", dest, ["http://m"], tmp_path / "dl", expect=("bin",) + ) + assert not (dest / ".esphome_extracted").exists() + + +def test_install_package_marker_rechecked_under_lock(tmp_path: Path) -> None: + """A concurrent install finishing while we wait for the lock is detected.""" + dest = tmp_path / "pkg" + marker = dest / ".esphome_extracted" + + @contextmanager + def _fake_lock(*_a, **_kw): + dest.mkdir(parents=True, exist_ok=True) + marker.touch() + yield + + with ( + patch("filelock.FileLock", _fake_lock), + patch.object(registry, "download_from_mirrors") as mock_download, + patch.object(registry, "rmdir") as mock_rmdir, + ): + registry.install_package( + "pkg", "1.0.0", dest, ["http://m"], tmp_path / "dl", expect=("payload",) + ) + mock_download.assert_not_called() + mock_rmdir.assert_not_called() + + +def test_install_package_uses_hard_lock(tmp_path: Path) -> None: + """The install lock must never degrade to a soft (existence) lock.""" + dest = tmp_path / "pkg" + with ( + patch("filelock.FileLock") as mock_lock, + patch.object(registry, "download_from_mirrors"), + patch.object(registry, "archive_extract_all") as mock_extract, + patch.object(registry, "get_systype", return_value="linux_x86_64"), + ): + mock_extract.side_effect = lambda *_a, **_kw: (dest / "payload").mkdir( + parents=True, exist_ok=True + ) + registry.install_package( + "pkg", "1.0.0", dest, ["http://m"], tmp_path / "dl", expect=("payload",) + ) + assert mock_lock.call_args.kwargs["fallback_to_soft"] is False + + +def test_registry_download_empty_system_list_does_not_match() -> None: + """An explicitly empty system list must not act as a wildcard.""" + with ( + _registry_response([{"system": [], "download_url": "http://x/any"}]), + patch.object(registry, "get_systype", return_value="linux_x86_64"), + pytest.raises(EsphomeError, match="No pkg 1.0.0 build"), + ): + registry.registry_download("pkg", "1.0.0") + + +def test_registry_download_unexpected_payload_is_named() -> None: + """An error envelope without a versions list is not 'version not found'.""" + + with ( + patch.object( + registry, + "http_request", + return_value=_http_response(json.dumps({"message": "rate limited"})), + ), + pytest.raises(EsphomeError, match="Unexpected package registry response"), + ): + registry.registry_download("pkg", "1.0.0") + + +def test_registry_download_missing_system_key_matches_any() -> None: + """A file with no system key at all serves every host.""" + with _registry_response( + [{"download_url": "http://x/any", "checksum": {"sha256": "abc"}, "size": 1}] + ): + assert registry.registry_download("pkg", "1.0.0") == ("http://x/any", "abc", 1) + + +def test_registry_download_missing_files_list_is_named() -> None: + """A version entry without a files list is an unexpected payload, not a + missing platform build.""" + with ( + _registry_response(None), + pytest.raises(EsphomeError, match="Unexpected package registry response"), + ): + registry.registry_download("pkg", "1.0.0") + + +def test_registry_download_missing_download_url_is_named() -> None: + with ( + _registry_response([{"system": "*", "checksum": {"sha256": "abc"}, "size": 1}]), + pytest.raises(EsphomeError, match="no download URL"), + ): + registry.registry_download("pkg", "1.0.0") + + +def test_install_package_empty_expect_rejected(tmp_path: Path) -> None: + """Layout validation is the only guard before marker.touch(), so an + empty expect is a caller bug, not a lenient install.""" + with pytest.raises(ValueError, match="non-empty expect"): + registry.install_package( + "pkg", "1.0.0", tmp_path / "pkg", [], tmp_path / "dl", expect=() + ) + + +def test_registry_download_non_dict_version_entry_is_named() -> None: + """A versions list of bare strings is an unexpected payload, not an + AttributeError traceback.""" + + with ( + patch.object( + registry, + "http_request", + return_value=_http_response(json.dumps({"versions": ["1.0.0", "2.0.0"]})), + ), + pytest.raises(EsphomeError, match="Unexpected package registry response"), + ): + registry.registry_download("pkg", "1.0.0") + + +def test_registry_download_non_dict_file_entry_is_named() -> None: + with ( + patch.object( + registry, + "http_request", + return_value=_http_response( + json.dumps({"versions": [{"name": "1.0.0", "files": ["a.tar.gz"]}]}) + ), + ), + pytest.raises(EsphomeError, match="Unexpected package registry response"), + ): + registry.registry_download("pkg", "1.0.0") + + +def test_registry_download_non_dict_payload_is_named() -> None: + """A JSON array answer is an unexpected payload at the outermost level.""" + + with ( + patch.object( + registry, + "http_request", + return_value=_http_response(json.dumps(["1.0.0"])), + ), + pytest.raises(EsphomeError, match="Unexpected package registry response"), + ): + registry.registry_download("pkg", "1.0.0") + + +def test_registry_download_non_list_system_is_named() -> None: + """A system field that is neither missing, str, nor list is an + unexpected payload, not a TypeError from the ``in`` test.""" + with ( + _registry_response([{"system": 5, "checksum": {"sha256": "abc"}, "size": 1}]), + pytest.raises(EsphomeError, match="Unexpected package registry response"), + ): + registry.registry_download("pkg", "1.0.0") + + +def _resolve_for(sizes: dict[str, int | None]): + def resolve(name: str, version: str): + size = sizes[name] + if size == -1: + raise EsphomeError("registry down") + return (f"http://x/{name}.tar.gz", "abc123", size) + + return resolve + + +def test_prefetch_packages_downloads_pending_in_parallel(tmp_path: Path) -> None: + """Two uninstalled packages download together under one combined bar, + with the registry's sha256 and size and a batch progress tracker.""" + with ( + patch.object(registry, "download_with_resume") as mock_download, + patch.object( + registry, "registry_download", side_effect=_resolve_for({"a": 10, "b": 20}) + ), + ): + registry.prefetch_packages( + [ + ("a", "1.0", tmp_path / "a", []), + ("b", "2.0", tmp_path / "b", []), + ], + tmp_path / "dl", + ) + assert mock_download.call_count == 2 + # Locking makes worker completion order nondeterministic + calls = sorted(mock_download.call_args_list, key=lambda c: c[0][0]) + for call, (name, version, size) in zip( + calls, [("a", "1.0", 10), ("b", "2.0", 20)], strict=True + ): + assert call[0][0] == f"http://x/{name}.tar.gz" + assert call[0][1] == tmp_path / "dl" / f"{name}-{version}" + assert call[1]["sha256"] == "abc123" + assert call[1]["size"] == size + assert callable(call[1]["progress"]) + + +def test_prefetch_packages_skips_freshly_installed_dest(tmp_path: Path) -> None: + """A dest whose marker appeared while the worker waited on the lock is + already installed; re-downloading would orphan an archive copy.""" + dest = tmp_path / "a" + dest.mkdir() + + from contextlib import contextmanager + + @contextmanager + def marker_appears_under_lock(path, **kwargs): + # Simulates the concurrent build finishing while we waited + (dest / ".esphome_extracted").touch() + yield + + with ( + patch("filelock.FileLock", side_effect=marker_appears_under_lock), + patch.object(registry, "download_with_resume") as mock_download, + patch.object( + registry, "registry_download", side_effect=_resolve_for({"a": 10}) + ), + ): + registry.prefetch_packages([("a", "1.0", dest, [])], tmp_path / "dl") + mock_download.assert_not_called() + + +def test_already_installed_probe(tmp_path: Path) -> None: + """Both arms of the marker probe the prefetch worker keys on.""" + dest = tmp_path / "pkg" + dest.mkdir() + assert registry._already_installed(dest) is False + (dest / ".esphome_extracted").touch() + assert registry._already_installed(dest) is True + + +def test_prefetch_packages_dedupes_duplicate_entries(tmp_path: Path) -> None: + """Duplicate (name, version) entries would race each other between two + workers; only one survives (and one is too few to parallelize).""" + with ( + patch.object(registry, "download_with_resume") as mock_download, + patch.object( + registry, "registry_download", side_effect=_resolve_for({"a": 10}) + ), + ): + registry.prefetch_packages( + [ + ("a", "1.0", tmp_path / "a", []), + ("a", "1.0", tmp_path / "a", []), + ], + tmp_path / "dl", + ) + mock_download.assert_not_called() + + +def test_prefetch_packages_single_pending_skips(tmp_path: Path) -> None: + """One pending package has nothing to parallelize; the sequential + install keeps its own bar.""" + marker_dest = tmp_path / "a" + marker_dest.mkdir() + (marker_dest / ".esphome_extracted").touch() + with ( + patch.object(registry, "download_with_resume") as mock_download, + patch.object( + registry, "registry_download", side_effect=_resolve_for({"b": 20}) + ), + ): + registry.prefetch_packages( + [ + ("a", "1.0", marker_dest, []), + ("b", "2.0", tmp_path / "b", []), + ], + tmp_path / "dl", + ) + mock_download.assert_not_called() + + +def test_prefetch_packages_mirror_and_sizeless_stay_sequential( + tmp_path: Path, +) -> None: + """Mirror overrides and size-less registry entries are left to the + sequential path so its per-file bars stay trustworthy.""" + with ( + patch.object(registry, "download_with_resume") as mock_download, + patch.object( + registry, + "registry_download", + side_effect=_resolve_for({"b": None, "c": 30}), + ), + ): + registry.prefetch_packages( + [ + ("a", "1.0", tmp_path / "a", ["http://mirror/{VERSION}"]), + ("b", "2.0", tmp_path / "b", []), + ("c", "3.0", tmp_path / "c", []), + ], + tmp_path / "dl", + ) + mock_download.assert_not_called() + + +def test_prefetch_packages_resolve_failure_defers_to_install( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A registry failure only skips the prefetch; install_package reports + the real error with context.""" + caplog.set_level("DEBUG") + with ( + patch.object(registry, "download_with_resume") as mock_download, + patch.object( + registry, "registry_download", side_effect=_resolve_for({"a": -1, "b": 20}) + ), + ): + registry.prefetch_packages( + [ + ("a", "1.0", tmp_path / "a", []), + ("b", "2.0", tmp_path / "b", []), + ], + tmp_path / "dl", + ) + mock_download.assert_not_called() + assert "Prefetch resolve for a failed" in caplog.text + + +def test_prefetch_packages_complete_archive_skipped(tmp_path: Path) -> None: + """An archive already fully downloaded is not re-fetched.""" + dl = tmp_path / "dl" + dl.mkdir() + (dl / "a-1.0").write_bytes(b"x" * 10) + with ( + patch.object(registry, "download_with_resume") as mock_download, + patch.object( + registry, "registry_download", side_effect=_resolve_for({"a": 10, "b": 20}) + ), + ): + registry.prefetch_packages( + [ + ("a", "1.0", tmp_path / "a", []), + ("b", "2.0", tmp_path / "b", []), + ], + dl, + ) + mock_download.assert_not_called() + + +def test_prefetch_packages_download_failure_is_debug( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A failed prefetch download is logged and left for install_package.""" + caplog.set_level("DEBUG") + with ( + patch.object( + registry, "download_with_resume", side_effect=OSError("boom") + ) as mock_download, + patch.object( + registry, "registry_download", side_effect=_resolve_for({"a": 10, "b": 20}) + ), + ): + registry.prefetch_packages( + [ + ("a", "1.0", tmp_path / "a", []), + ("b", "2.0", tmp_path / "b", []), + ], + tmp_path / "dl", + ) + assert mock_download.call_count == 2 + assert "Prefetch of a failed" in caplog.text + assert "Prefetch of b failed" in caplog.text + + +def test_prefetch_packages_unexpected_failure_warns( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """A programming error (not a download failure) surfaces at WARNING + instead of becoming a permanent silent no-op.""" + with ( + patch.object( + registry, "download_with_resume", side_effect=TypeError("bad call") + ), + patch.object( + registry, "registry_download", side_effect=_resolve_for({"a": 10, "b": 20}) + ), + ): + registry.prefetch_packages( + [ + ("a", "1.0", tmp_path / "a", []), + ("b", "2.0", tmp_path / "b", []), + ], + tmp_path / "dl", + ) + assert "TypeError" in caplog.text diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index 28304270a4..63c40f3609 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -431,8 +431,8 @@ def test_ccache_env_enabled_by_default(setup_core: Path) -> None: with ( patch.dict(os.environ, {}, clear=True), - patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), - patch.object(toolchain.subprocess, "run"), + patch("shutil.which", return_value="/usr/bin/ccache"), + patch("esphome.framework_helpers.subprocess.run"), ): env = toolchain._ccache_env() @@ -469,7 +469,7 @@ def test_ccache_env_disabled_without_binary( with ( patch.dict(os.environ, env_vars, clear=True), - patch.object(toolchain.shutil, "which", return_value=None), + patch("shutil.which", return_value=None), caplog.at_level("WARNING"), ): env = toolchain._ccache_env() @@ -494,8 +494,8 @@ def test_ccache_env_disabled_when_probe_fails( with ( patch.dict(os.environ, {}, clear=True), - patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), - patch.object(toolchain.subprocess, "run", side_effect=probe_error), + patch("shutil.which", return_value="/usr/bin/ccache"), + patch("esphome.framework_helpers.subprocess.run", side_effect=probe_error), ): env = toolchain._ccache_env() @@ -508,8 +508,8 @@ def test_ccache_env_forced_on_skips_probe(setup_core: Path) -> None: with ( patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "1"}, clear=True), - patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), - patch.object(toolchain.subprocess, "run") as mock_probe, + patch("shutil.which", return_value="/usr/bin/ccache"), + patch("esphome.framework_helpers.subprocess.run") as mock_probe, ): env = toolchain._ccache_env() @@ -537,9 +537,9 @@ def test_ccache_env_strips_win_long_path_prefix(setup_core: Path) -> None: patch.dict(os.environ, {}, clear=True), # shutil.which is patched, so the win32 code path of the real # implementation (which crashes on a POSIX host) is never reached. - patch("esphome.platformio.toolchain.sys.platform", "win32"), - patch.object(toolchain.shutil, "which", return_value=prefixed), - patch.object(toolchain.subprocess, "run") as mock_probe, + patch("esphome.framework_helpers.sys.platform", "win32"), + patch("shutil.which", return_value=prefixed), + patch("esphome.framework_helpers.subprocess.run") as mock_probe, ): env = toolchain._ccache_env() @@ -555,7 +555,7 @@ def test_ccache_env_opt_out(setup_core: Path) -> None: with ( patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "0"}, clear=True), - patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch("shutil.which", return_value="/usr/bin/ccache"), ): env = toolchain._ccache_env() @@ -568,7 +568,7 @@ def test_ccache_env_normalizes_enable_value(setup_core: Path) -> None: with ( patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "yes"}, clear=True), - patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), + patch("shutil.which", return_value="/usr/bin/ccache"), ): env = toolchain._ccache_env() @@ -587,8 +587,8 @@ def test_ccache_env_respects_user_values_and_refreshes_basedir( with ( patch.dict(os.environ, user_env, clear=True), - patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), - patch.object(toolchain.subprocess, "run"), + patch("shutil.which", return_value="/usr/bin/ccache"), + patch("esphome.framework_helpers.subprocess.run"), ): env = toolchain._ccache_env() @@ -606,8 +606,8 @@ def test_run_platformio_cli_passes_ccache_env_to_subprocess_only( with ( patch.dict(os.environ, {}, clear=False), - patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), - patch.object(toolchain.subprocess, "run"), + patch("shutil.which", return_value="/usr/bin/ccache"), + patch("esphome.framework_helpers.subprocess.run"), ): os.environ.pop("ESPHOME_CCACHE_ENABLE", None) mock_run_external_process.return_value = 0 @@ -628,8 +628,8 @@ def test_ccache_env_requires_build_path(setup_core: Path) -> None: with ( patch.dict(os.environ, {}, clear=True), - patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), - patch.object(toolchain.subprocess, "run"), + patch("shutil.which", return_value="/usr/bin/ccache"), + patch("esphome.framework_helpers.subprocess.run"), pytest.raises(ValueError, match="CORE.build_path must be set"), ): toolchain._ccache_env() @@ -642,8 +642,8 @@ def test_run_platformio_cli_merges_caller_env( CORE.build_path = str(setup_core / "build" / "test") with ( - patch.object(toolchain.shutil, "which", return_value="/usr/bin/ccache"), - patch.object(toolchain.subprocess, "run"), + patch("shutil.which", return_value="/usr/bin/ccache"), + patch("esphome.framework_helpers.subprocess.run"), ): mock_run_external_process.return_value = 0 toolchain.run_platformio_cli( @@ -800,9 +800,7 @@ def test_ccache_env_real_probe_runs_stripped_path(setup_core: Path) -> None: with ( patch.dict(os.environ, {}, clear=False), - patch.object( - toolchain.shutil, "which", return_value="\\\\?\\" + sys.executable - ), + patch("shutil.which", return_value="\\\\?\\" + sys.executable), ): os.environ.pop("ESPHOME_CCACHE_ENABLE", None) env = toolchain._ccache_env() @@ -843,40 +841,6 @@ def test_ccache_wrapper_through_cmd_exe( assert marker.read_text() == "compiled" -@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.toolchain.sys.platform", platform): - assert toolchain._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: @@ -900,7 +864,7 @@ def test_run_platformio_cli_strips_win_long_path_prefix( # so the stdlib sees it too) would send shutil.which down the Windows # code path, which crashes on a POSIX host. patch.dict(os.environ, {"ESPHOME_CCACHE_ENABLE": "0"}, clear=False), - patch("esphome.platformio.toolchain.sys.platform", "win32"), + patch("esphome.framework_helpers.sys.platform", "win32"), patch("esphome.platformio.toolchain.sys.executable", prefixed_exe), ): # Pop any pre-existing PYTHONEXEPATH so the assertion below reflects @@ -932,7 +896,7 @@ def test_run_platformio_cli_does_not_set_pythonexepath_without_strip( with ( patch.dict(os.environ, {}, clear=False), - patch("esphome.platformio.toolchain.sys.platform", "linux"), + patch("esphome.framework_helpers.sys.platform", "linux"), patch("esphome.platformio.toolchain.sys.executable", plain_exe), ): os.environ.pop("PYTHONEXEPATH", None) @@ -1977,10 +1941,3 @@ def test_run_platformio_cli_invokes_heal( with patch.object(toolchain, "heal_platformio_python_env") as mock_heal: toolchain.run_platformio_cli("test") mock_heal.assert_called_once() - - -def test_ccache_probe_spawns_with_close_fds_false() -> None: - """The probe follows the repo-wide posix_spawn convention.""" - with patch("subprocess.run") as mock_run: - assert toolchain._ccache_runs("/usr/bin/ccache") is True - assert mock_run.call_args.kwargs["close_fds"] is False diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 0a53dba9c2..9c20ee10d2 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -14,6 +14,7 @@ from unittest.mock import MagicMock, patch import pytest +from esphome.build_helpers.tools_cache import TOOLS_CACHE_SPECS from esphome.const import ( PLATFORM_BK72XX, PLATFORM_ESP32, @@ -68,15 +69,12 @@ def _isolate_platformio_paths(tmp_path_factory: pytest.TempPathFactory) -> Any: test_clean_all_partial_exists) install their own inner patch which stacks on top of this one and wins for the duration of their block. - Also pin ``ESPHOME_ESP_IDF_PREFIX`` and ``ESPHOME_SDK_NRF_PREFIX`` to - nonexistent tmp dirs, and patch ``platformdirs.user_cache_dir``, for the - same reason: ``clean_all`` removes the machine-global toolchain installs + Also pin every ``TOOLS_CACHE_SPECS`` env override to a nonexistent tmp + dir, and patch ``platformdirs.user_cache_dir``, for the same reason: ``clean_all`` removes the machine-global toolchain installs and their default cache root, which otherwise resolve to the real ``~/.cache/esphome``. """ pio_root = tmp_path_factory.mktemp("isolated_pio") / "nonexistent" - idf_root = tmp_path_factory.mktemp("isolated_idf") / "nonexistent" - sdk_nrf_root = tmp_path_factory.mktemp("isolated_sdk_nrf") / "nonexistent" cache_root = tmp_path_factory.mktemp("isolated_cache") / "nonexistent" mock_cfg = MagicMock() mock_cfg.get.side_effect = lambda section, option: ( @@ -90,8 +88,12 @@ def _isolate_platformio_paths(tmp_path_factory: pytest.TempPathFactory) -> Any: patch.dict( "os.environ", { - "ESPHOME_ESP_IDF_PREFIX": str(idf_root), - "ESPHOME_SDK_NRF_PREFIX": str(sdk_nrf_root), + # Derived from the registry so a new backend's cache can + # never drift out of the sandbox and hit a real toolchain + env_var: str( + tmp_path_factory.mktemp(f"isolated_{subdir}") / "nonexistent" + ) + for env_var, subdir in TOOLS_CACHE_SPECS }, ), patch("platformdirs.user_cache_dir", return_value=str(cache_root)), From 9e4c52989c6015be0a95ff0761c4c9a935b44ef1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Aug 2026 20:24:47 -0500 Subject: [PATCH 08/30] [esp8266] Add the native framework and toolchain installer (#18557) --- esphome/arduino8266/__init__.py | 9 + esphome/arduino8266/framework.py | 164 +++++++++++++++++ esphome/components/esp8266/__init__.py | 11 +- .../unit_tests/test_arduino8266_framework.py | 170 ++++++++++++++++++ tests/unit_tests/test_writer.py | 22 +++ 5 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 esphome/arduino8266/__init__.py create mode 100644 esphome/arduino8266/framework.py create mode 100644 tests/unit_tests/test_arduino8266_framework.py diff --git a/esphome/arduino8266/__init__.py b/esphome/arduino8266/__init__.py new file mode 100644 index 0000000000..8f403a8553 --- /dev/null +++ b/esphome/arduino8266/__init__.py @@ -0,0 +1,9 @@ +"""Native (PlatformIO-free) build support for the ESP8266 Arduino core. + +This package downloads the Arduino ESP8266 core and the xtensa-lx106 +toolchain, generates a ninja build for them plus the ESPHome sources, and +drives the build directly — the ESP8266 equivalent of ``esphome.espidf``. + +Deliberately importable without the esp8266 component to avoid circular +imports; the component wires these modules in via lazy imports. +""" diff --git a/esphome/arduino8266/framework.py b/esphome/arduino8266/framework.py new file mode 100644 index 0000000000..1edbe4b36f --- /dev/null +++ b/esphome/arduino8266/framework.py @@ -0,0 +1,164 @@ +"""Download and install the Arduino ESP8266 core, toolchain, and ninja. + +Artifacts land in a machine-global cache (shared across projects, like the +ESP-IDF install in ``esphome.espidf.framework``): + + /arduino8266/frameworks// framework-arduinoespressif8266 + /arduino8266/toolchains// toolchain-xtensa (gcc 10.3) + +Packages come from the PlatformIO registry (identical bits to the PlatformIO +backend); ``ESPHOME_ARDUINO8266_*_MIRRORS`` overrides the URLs. ninja comes +from PATH or the ninja PyPI wheel. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import NamedTuple + +from esphome.build_helpers.ccache import ccache_defaults_env +from esphome.build_helpers.ninja import find_ninja +from esphome.build_helpers.tools_cache import ARDUINO8266_TOOLS_CACHE, tools_cache_path +from esphome.core import EsphomeError, Version +from esphome.framework_helpers import str_to_lst_of_str +from esphome.platformio.registry import install_package, prefetch_packages + +FRAMEWORK_PACKAGE = "framework-arduinoespressif8266" +TOOLCHAIN_PACKAGE = "toolchain-xtensa" +# gcc 10.3, the toolchain Arduino core 3.x builds with; the build +# generator's compile flags are tuned to it. +TOOLCHAIN_VERSION = "2.100300.220621" + +ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS = str_to_lst_of_str( + os.environ.get("ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS", "") +) +ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS = str_to_lst_of_str( + os.environ.get("ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS", "") +) + + +def get_arduino8266_tools_path() -> Path: + # Machine-global so all projects share one install; see + # espidf.framework.get_idf_tools_path for the location rationale. + return tools_cache_path(*ARDUINO8266_TOOLS_CACHE) + + +# 3.1.1 rather than 3.1.0: the registry has no package for 3.1.0, and the +# encoder below cannot name 3.0.0/3.0.1 either (see its docstring) +MIN_FRAMEWORK_VERSION = Version(3, 1, 1) + + +def framework_package_version(ver: Version) -> str: + """Map an Arduino core version to its registry package version (3.1.2 -> + 3.30102.0; the leading 3 is the package major). + + Exact registry names only for cores > 2.6.2 and >= 3.0.2; callers floor + at MIN_FRAMEWORK_VERSION. + """ + if ver.major > 3: + raise EsphomeError( + f"Arduino core {ver} is not supported yet; " + "the newest known core series is 3.x" + ) + if ver <= Version(2, 6, 2): + # Cores <= 2.6.2 use the older 1.x/2.x package-major encodings (same + # boundary as _format_framework_arduino_version's era guard) + raise EsphomeError( + f"Arduino core {ver} uses an older package encoding than this " + "helper implements (newer than 2.6.2)" + ) + return f"3.{ver.major}{ver.minor:02d}{ver.patch:02d}.0" + + +def get_framework_path(package_version: str) -> Path: + return get_arduino8266_tools_path() / "frameworks" / package_version + + +def get_toolchain_path() -> Path: + return get_arduino8266_tools_path() / "toolchains" / TOOLCHAIN_VERSION + + +class InstalledPaths(NamedTuple): + """Locations of the installed framework, toolchain, and ninja binary.""" + + framework: Path + toolchain: Path + ninja: Path + + +def check_and_install(framework_version: Version) -> InstalledPaths: + """Ensure framework, toolchain, and ninja are installed; return their paths.""" + if framework_version < MIN_FRAMEWORK_VERSION: + # Config validation enforces this too; keep the module honest when + # called directly. + raise EsphomeError( + f"The native toolchain requires the Arduino core " + f">= {MIN_FRAMEWORK_VERSION}, got {framework_version}" + ) + # Probe the cheap local dependency before ~110 MB of downloads + ninja_path = find_ninja() + package_version = framework_package_version(framework_version) + framework_path = get_framework_path(package_version) + downloads_dir = get_arduino8266_tools_path() / "downloads" + toolchain_path = get_toolchain_path() + # One spec per package: the prefetch and the installs must agree + specs = ( + ( + FRAMEWORK_PACKAGE, + package_version, + framework_path, + ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS, + ("cores/esp8266", "tools/sdk", "libraries"), + ), + ( + TOOLCHAIN_PACKAGE, + TOOLCHAIN_VERSION, + toolchain_path, + ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS, + # xtensa-lx106-elf pins the target: every gcc package has a bin/ + ("bin", "xtensa-lx106-elf"), + ), + ) + # Fetch both archives at once; the installs below verify and extract + prefetch_packages([spec[:4] for spec in specs], downloads_dir) + for name, version, dest, mirrors, expect in specs: + install_package(name, version, dest, mirrors, downloads_dir, expect=expect) + return InstalledPaths( + framework=framework_path, toolchain=toolchain_path, ninja=ninja_path + ) + + +def toolchain_tool(toolchain_path: Path, name: str) -> Path: + """Path to one toolchain tool (gcc, g++, ar, size, addr2line, ...). + + The single owner of the ``bin/xtensa-lx106-elf-`` layout and the + Windows suffix, so a toolchain package bump touches one spot. + """ + suffix = ".exe" if os.name == "nt" else "" + return toolchain_path / "bin" / f"xtensa-lx106-elf-{name}{suffix}" + + +def get_build_env(toolchain_path: Path, ccache: str | None) -> dict[str, str]: + env = os.environ.copy() + # Drop empty entries: a trailing separator from an absent PATH would + # make the shell search the current directory for tools + parts = [ + str(toolchain_path / "bin"), + *filter(None, env.get("PATH", "").split(os.pathsep)), + ] + env["PATH"] = os.pathsep.join(parts) + env.update(ccache_env(ccache)) + return env + + +def ccache_env(ccache: str | None) -> dict[str, str]: + """Return ccache settings for the build subprocess (not os.environ). + + ``ccache`` is the pre-resolved binary (resolve_ccache_path), or None + when disabled. Values the user already set in the environment are + respected. + """ + if ccache is None: + return {} + return ccache_defaults_env(get_arduino8266_tools_path() / "ccache") diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 6f29cd7774..63665e7681 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -137,7 +137,16 @@ def _format_framework_arduino_version(ver: cv.Version) -> str: return f"~1.{ver.major}{ver.minor:02d}{ver.patch:02d}.0" if ver <= cv.Version(2, 6, 2): return f"~2.{ver.major}{ver.minor:02d}{ver.patch:02d}.0" - return f"~3.{ver.major}{ver.minor:02d}{ver.patch:02d}.0" + # Same encoding the native toolchain uses for its package download, so a + # version bump cannot drift between the two paths. + from esphome.arduino8266.framework import framework_package_version + + try: + return f"~{framework_package_version(ver)}" + except EsphomeError as err: + # Anchor the 4.x rejection to the framework version line instead of + # aborting with a bare traceback-level error + raise cv.Invalid(str(err), path=[CONF_VERSION]) from err # NOTE: Keep this in mind when updating the recommended version: diff --git a/tests/unit_tests/test_arduino8266_framework.py b/tests/unit_tests/test_arduino8266_framework.py new file mode 100644 index 0000000000..bd0a620e10 --- /dev/null +++ b/tests/unit_tests/test_arduino8266_framework.py @@ -0,0 +1,170 @@ +"""Tests for esphome.arduino8266.framework (downloads and environment).""" + +from __future__ import annotations + +import os +from pathlib import Path +from unittest.mock import patch + +import pytest + +from esphome.arduino8266 import framework +import esphome.config_validation as cv +from esphome.core import CORE, EsphomeError + + +@pytest.fixture(autouse=True) +def _build_path(tmp_path: Path) -> None: + CORE.build_path = tmp_path + + +def test_framework_package_version() -> None: + assert framework.framework_package_version(cv.Version(3, 1, 2)) == "3.30102.0" + assert framework.framework_package_version(cv.Version(3, 2, 0)) == "3.30200.0" + # 2.6.3+ cores use the same package-major-3 encoding (PlatformIO path) + assert framework.framework_package_version(cv.Version(2, 7, 4)) == "3.20704.0" + # A future major bump needs its own encoding, not a doomed registry lookup + with pytest.raises(EsphomeError, match="not supported yet"): + framework.framework_package_version(cv.Version(4, 0, 0)) + # The boundary matches the PlatformIO era guard; a 2.6.2 pre-release + # keeps this encoding + with pytest.raises(EsphomeError, match="older package encoding"): + framework.framework_package_version(cv.Version(2, 6, 2)) + assert framework.framework_package_version(cv.Version(2, 6, 2, "b1")) == "3.20602.0" + assert framework.framework_package_version(cv.Version(2, 6, 3)) == "3.20603.0" + + +def test_format_framework_arduino_version_pins_all_series() -> None: + """The esp8266 component's PIO source formatter across every encoding + era, including the 4.x rejection it now shares with the installer.""" + from esphome.components.esp8266 import _format_framework_arduino_version as fmt + + assert fmt(cv.Version(2, 4, 1)) == "~1.20401.0" + assert fmt(cv.Version(2, 6, 2)) == "~2.20602.0" + assert fmt(cv.Version(2, 7, 4)) == "~3.20704.0" + assert fmt(cv.Version(3, 1, 2)) == "~3.30102.0" + # Anchored to the framework version line, not a bare EsphomeError + with pytest.raises(cv.Invalid, match="not supported yet") as excinfo: + fmt(cv.Version(4, 0, 0)) + assert excinfo.value.path == ["version"] + + +def test_tools_path_default_and_prefix(tmp_path: Path) -> None: + with patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": str(tmp_path)}): + assert framework.get_arduino8266_tools_path() == tmp_path.resolve() + # A blank prefix must be treated as unset, not as the CWD + with patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": " "}): + path = framework.get_arduino8266_tools_path() + assert path.name == "arduino8266" + assert path != Path.cwd() + + +def test_check_and_install_returns_paths(tmp_path: Path) -> None: + with ( + patch.dict(os.environ, {"ESPHOME_ARDUINO8266_PREFIX": str(tmp_path)}), + patch.object(framework, "install_package") as mock_install, + patch.object(framework, "prefetch_packages") as mock_prefetch, + patch.object(framework, "find_ninja", return_value=tmp_path / "ninja"), + ): + paths = framework.check_and_install(cv.Version(3, 1, 2)) + assert paths.framework == tmp_path / "frameworks" / "3.30102.0" + assert paths.toolchain == tmp_path / "toolchains" / framework.TOOLCHAIN_VERSION + assert paths.ninja == tmp_path / "ninja" + assert mock_install.call_count == 2 + # Full argument pinning: a copy-paste swap between the two near-identical + # calls (mirrors, destination) must not stay green + fw_call, tc_call = mock_install.call_args_list + assert fw_call.args == ( + framework.FRAMEWORK_PACKAGE, + "3.30102.0", + tmp_path / "frameworks" / "3.30102.0", + framework.ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS, + tmp_path / "downloads", + ) + assert fw_call.kwargs["expect"] == ("cores/esp8266", "tools/sdk", "libraries") + assert tc_call.args == ( + framework.TOOLCHAIN_PACKAGE, + framework.TOOLCHAIN_VERSION, + tmp_path / "toolchains" / framework.TOOLCHAIN_VERSION, + framework.ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS, + tmp_path / "downloads", + ) + assert tc_call.kwargs["expect"] == ("bin", "xtensa-lx106-elf") + # The prefetch sees the same package specs as the installs + assert mock_prefetch.call_args.args == ( + [ + ( + framework.FRAMEWORK_PACKAGE, + "3.30102.0", + tmp_path / "frameworks" / "3.30102.0", + framework.ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS, + ), + ( + framework.TOOLCHAIN_PACKAGE, + framework.TOOLCHAIN_VERSION, + tmp_path / "toolchains" / framework.TOOLCHAIN_VERSION, + framework.ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS, + ), + ], + tmp_path / "downloads", + ) + + +def test_get_build_env_prepends_toolchain_bin(tmp_path: Path) -> None: + with patch.object(framework, "ccache_env", return_value={"CCACHE_DIR": "x"}): + env = framework.get_build_env(tmp_path, None) + assert env["PATH"].startswith(str(tmp_path / "bin") + os.pathsep) + assert env["CCACHE_DIR"] == "x" + + +def test_ccache_env(tmp_path: Path) -> None: + assert framework.ccache_env(None) == {} + with patch.dict(os.environ, {"CCACHE_NOHASHDIR": "false"}, clear=True): + env = framework.ccache_env("/usr/bin/ccache") + # User-set values are respected; the rest get defaults + assert "CCACHE_NOHASHDIR" not in env + assert env["CCACHE_DEPEND"] == "1" + assert env["CCACHE_BASEDIR"] == str(Path(CORE.build_path).resolve()) + assert env["CCACHE_DIR"].endswith("ccache") + + +def test_check_and_install_rejects_old_core(tmp_path: Path) -> None: + """Calling the installer below the floor fails before any download.""" + with pytest.raises(EsphomeError, match=">= 3.1.1"): + framework.check_and_install(cv.Version(3, 0, 2)) + + +def test_get_build_env_without_path_has_no_empty_entry(tmp_path: Path) -> None: + """An absent PATH must not leave a trailing separator (an empty entry + means the current directory to the shell).""" + with ( + patch.dict(os.environ, {}, clear=True), + patch.object(framework, "ccache_env", return_value={}), + ): + env = framework.get_build_env(tmp_path, None) + assert env["PATH"] == str(tmp_path / "bin") + with ( + patch.dict( + os.environ, {"PATH": f"/usr/bin{os.pathsep}{os.pathsep}/bin"}, clear=True + ), + patch.object(framework, "ccache_env", return_value={}), + ): + env = framework.get_build_env(tmp_path, None) + assert env["PATH"].split(os.pathsep) == [str(tmp_path / "bin"), "/usr/bin", "/bin"] + + +def test_ccache_env_accepts_a_preresolved_path() -> None: + """The caller resolves ccache once and threads it through; None means + resolved-and-disabled.""" + with patch.dict(os.environ, {}, clear=True): + assert framework.ccache_env(None) == {} + env = framework.ccache_env("/usr/bin/ccache") + assert env["CCACHE_DIR"].endswith("ccache") + + +def test_toolchain_tool_layout(tmp_path: Path) -> None: + """One owner for the bin/xtensa-lx106-elf- layout.""" + tool = framework.toolchain_tool(tmp_path, "addr2line") + assert tool.parent == tmp_path / "bin" + assert tool.name.startswith("xtensa-lx106-elf-addr2line") + assert (tool.suffix == ".exe") is (os.name == "nt") diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index 9c20ee10d2..47feae3e3c 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -1059,6 +1059,28 @@ def test_clean_all_removes_global_sdk_nrf_install( assert str(sdk_nrf_install.resolve()) in caplog.text +@patch("esphome.writer.CORE") +def test_clean_all_removes_global_arduino8266_install( + mock_core: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """clean_all removes the machine-global native arduino8266 install dir.""" + arduino8266_install = tmp_path / "arduino8266_install" + (arduino8266_install / "frameworks").mkdir(parents=True) + monkeypatch.setenv("ESPHOME_ARDUINO8266_PREFIX", str(arduino8266_install)) + + config_dir = tmp_path / "config" + config_dir.mkdir() + + with caplog.at_level("INFO"): + clean_all([str(config_dir)]) + + assert not arduino8266_install.exists() + assert str(arduino8266_install.resolve()) in caplog.text + + @patch("esphome.writer.CORE") def test_clean_all_removes_default_cache_root( mock_core: MagicMock, From 8b8de0c9c65923a845981ad317dd3ad919697040 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:33:39 +1000 Subject: [PATCH 09/30] [lvgl] Fix on_value/on_update triggers for LVGL select entities (#18778) Co-authored-by: Claude Sonnet 5 --- esphome/components/lvgl/lvgl_esphome.cpp | 8 ++-- esphome/components/lvgl/lvgl_esphome.h | 6 +-- esphome/components/lvgl/select/lvgl_select.h | 15 ++----- esphome/components/lvgl/types.py | 3 ++ .../dropdown_update_fires_event_test.yaml | 36 ++++++++++++++++ .../lvgl/test_dropdown_update_fires_event.py | 41 +++++++++++++++++++ 6 files changed, 90 insertions(+), 19 deletions(-) create mode 100644 tests/component_tests/lvgl/config/dropdown_update_fires_event_test.yaml create mode 100644 tests/component_tests/lvgl/test_dropdown_update_fires_event.py diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 684f472ebd..a10fdb0582 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -597,21 +597,21 @@ std::string LvSelectable::get_selected_text() { return this->options_[selected]; } -static std::string join_string(std::vector options) { +static std::string join_string(const FixedVector &options) { return std::accumulate( options.begin(), options.end(), std::string(), - [](const std::string &a, const std::string &b) -> std::string { return a + (!a.empty() ? "\n" : "") + b; }); + [](const std::string &a, const char *b) -> std::string { return a + (!a.empty() ? "\n" : "") + b; }); } void LvSelectable::set_selected_text(const std::string &text, lv_anim_enable_t anim) { - auto index = std::find(this->options_.begin(), this->options_.end(), text); + 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_update_event, nullptr); } } -void LvSelectable::set_options(std::vector options) { +void LvSelectable::set_options(FixedVector options) { auto index = this->get_selected_index(); if (index >= options.size()) index = options.size() - 1; diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index ceba786e43..8b7397c4cd 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -543,12 +543,12 @@ class LvSelectable : public LvCompound { virtual void set_selected_index(size_t index, lv_anim_enable_t anim) = 0; void set_selected_text(const std::string &text, lv_anim_enable_t anim); std::string get_selected_text(); - const std::vector &get_options() { return this->options_; } - void set_options(std::vector options); + const FixedVector &get_options() { return this->options_; } + void set_options(FixedVector options); protected: virtual void set_option_string(const char *options) = 0; - std::vector options_{}; + FixedVector options_{}; }; #ifdef USE_LVGL_DROPDOWN diff --git a/esphome/components/lvgl/select/lvgl_select.h b/esphome/components/lvgl/select/lvgl_select.h index e36357328c..dafdd91eb5 100644 --- a/esphome/components/lvgl/select/lvgl_select.h +++ b/esphome/components/lvgl/select/lvgl_select.h @@ -50,19 +50,10 @@ class LVGLSelect final : public select::Select, public Component { protected: void control(size_t index) override { this->widget_->set_selected_index(index, this->anim_); - this->publish(); - } - void set_options_() { - // Widget uses std::vector, SelectTraits uses FixedVector - // Convert by extracting c_str() pointers - const auto &opts = this->widget_->get_options(); - FixedVector opt_ptrs; - opt_ptrs.init(opts.size()); - for (const auto &opt : opts) { - opt_ptrs.push_back(opt.c_str()); - } - this->traits.set_options(opt_ptrs); + // The update event fires the widget's on_value/on_update triggers + lv_obj_send_event(this->widget_->obj, lv_update_event, nullptr); } + void set_options_() { this->traits.set_options(this->widget_->get_options()); } LvSelectable *widget_; lv_anim_enable_t anim_; diff --git a/esphome/components/lvgl/types.py b/esphome/components/lvgl/types.py index 61efe385e6..cc8d9438a9 100644 --- a/esphome/components/lvgl/types.py +++ b/esphome/components/lvgl/types.py @@ -3,6 +3,8 @@ 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 CONF_SELECTED_INDEX + class LvType(cg.MockObjClass): def __init__(self, *args, **kwargs): @@ -112,3 +114,4 @@ class LvSelect(LvType): parents=parens, **kwargs, ) + self.value_property = CONF_SELECTED_INDEX diff --git a/tests/component_tests/lvgl/config/dropdown_update_fires_event_test.yaml b/tests/component_tests/lvgl/config/dropdown_update_fires_event_test.yaml new file mode 100644 index 0000000000..2fe59b2f1a --- /dev/null +++ b/tests/component_tests/lvgl/config/dropdown_update_fires_event_test.yaml @@ -0,0 +1,36 @@ +esphome: + name: test-dropdown-update-event + on_boot: + - lvgl.dropdown.update: + id: test_dropdown + selected_index: 2 + +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: GPIO3 + +lvgl: + widgets: + - dropdown: + id: test_dropdown + options: + - First + - Second + - Third + on_update: + - lambda: |- + ESP_LOGD("test", "dropdown updated"); diff --git a/tests/component_tests/lvgl/test_dropdown_update_fires_event.py b/tests/component_tests/lvgl/test_dropdown_update_fires_event.py new file mode 100644 index 0000000000..1e034ad6eb --- /dev/null +++ b/tests/component_tests/lvgl/test_dropdown_update_fires_event.py @@ -0,0 +1,41 @@ +"""Regression test: lvgl.dropdown.update with selected_index must fire on_value/on_update. + +LvSelect (backing both dropdown and roller) did not set `value_property`, so the generic +update-action machinery in automation.py never sent the synthetic update event for a +`selected_index:` change made via `lvgl.dropdown.update`/`lvgl.roller.update`, unlike `value:` +on number widgets or `text:` on text widgets. Fixed by setting `LvSelect.value_property` to +`CONF_SELECTED_INDEX`. +""" + +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: + config_path = ( + Path(request.fspath).parent / "config" / "dropdown_update_fires_event_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_main_section + finally: + CORE.config_path = original_path + CORE.reset() + + +def test_dropdown_update_sends_update_event(main_cpp: str) -> None: + assert ( + "lv_obj_send_event(test_dropdown->obj, lvgl::lv_update_event, nullptr)" + in main_cpp + ) From cb981c2930f2efe813b59f7dc5beedbe82cda55b Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 25 Aug 2026 22:19:54 -0500 Subject: [PATCH 10/30] [remote_transmitter] ISR-driven transmission and non_blocking support on RTL8720C (#18648) --- .../components/remote_transmitter/__init__.py | 16 +- .../remote_transmitter/remote_transmitter.h | 31 +- .../remote_transmitter_rtl87xx.cpp | 285 ++++++++++++++++-- .../remote_transmitter/__init__.py | 0 .../test_non_blocking_gate.py | 42 +++ .../remote_transmitter/test.rtl87xx-ard.yaml | 1 + 6 files changed, 350 insertions(+), 25 deletions(-) create mode 100644 tests/component_tests/remote_transmitter/__init__.py create mode 100644 tests/component_tests/remote_transmitter/test_non_blocking_gate.py diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index 9d8761ea90..8ae51829e7 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -3,6 +3,8 @@ import logging from esphome import automation, pins import esphome.codegen as cg from esphome.components import esp32, esp32_rmt, remote_base +from esphome.components.libretiny import get_libretiny_family +from esphome.components.libretiny.const import FAMILY_RTL8720C from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( @@ -43,6 +45,16 @@ DigitalWriteAction = remote_transmitter_ns.class_( ) +def _validate_non_blocking_platform(value: bool) -> bool: + # non_blocking requires hardware transmission: RMT on ESP32, the gtimer + # envelope chain on RTL8720C. Reject everywhere else at config time. + if CORE.is_esp32: + return cv.boolean(value) + if CORE.is_libretiny and get_libretiny_family() == FAMILY_RTL8720C: + return cv.boolean(value) + raise cv.Invalid("non_blocking is only supported on ESP32 and RTL8720C") + + MULTI_CONF = True CONFIG_SCHEMA = ( cv.Schema( @@ -76,7 +88,7 @@ CONFIG_SCHEMA = ( esp32_s2=64, esp32_s3=48, ): cv.All(cv.only_on_esp32, cv.int_range(min=2)), - cv.Optional(CONF_NON_BLOCKING): cv.All(cv.only_on_esp32, cv.boolean), + cv.Optional(CONF_NON_BLOCKING): _validate_non_blocking_platform, cv.Optional(CONF_ON_TRANSMIT): automation.validate_automation(single=True), cv.Optional(CONF_ON_COMPLETE): automation.validate_automation(single=True), } @@ -164,6 +176,8 @@ async def to_code(config: ConfigType) -> None: ) else: var = cg.new_Pvariable(config[CONF_ID], pin) + if (non_blocking := config.get(CONF_NON_BLOCKING)) is not None: + cg.add(var.set_non_blocking(non_blocking)) await cg.register_component(var, config) cg.add(var.set_carrier_duty_percent(config[CONF_CARRIER_DUTY_PERCENT])) diff --git a/esphome/components/remote_transmitter/remote_transmitter.h b/esphome/components/remote_transmitter/remote_transmitter.h index 94bcb74b09..ef9a80f668 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.h +++ b/esphome/components/remote_transmitter/remote_transmitter.h @@ -56,15 +56,23 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa #if defined(USE_ESP32) && SOC_RMT_SUPPORTED void set_with_dma(bool with_dma) { this->with_dma_ = with_dma; } void set_eot_level(bool eot_level) { this->eot_level_ = eot_level; } +#endif +#if (defined(USE_ESP32) && SOC_RMT_SUPPORTED) || defined(USE_LIBRETINY_VARIANT_RTL8720C) void set_non_blocking(bool non_blocking) { this->non_blocking_ = non_blocking; } #endif +#ifdef USE_LIBRETINY_VARIANT_RTL8720C + void loop() override; + // called from the envelope timer ISR trampoline; not part of the public API + void advance_envelope_isr(); +#endif Trigger<> *get_transmit_trigger() { return &this->transmit_trigger_; } Trigger<> *get_complete_trigger() { return &this->complete_trigger_; } protected: void send_internal(uint32_t send_times, uint32_t send_wait) override; -#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) +#if defined(USE_ESP8266) || (defined(USE_LIBRETINY) && !defined(USE_LIBRETINY_VARIANT_RTL8720C)) || \ + defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) void await_target_time_(); uint32_t target_time_{0}; #endif @@ -81,6 +89,27 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa uint32_t current_carrier_frequency_{0}; void *pwm_{nullptr}; // pwmout_t*, opaque here to keep the SDK header out of this shared header #endif +#ifdef USE_LIBRETINY_VARIANT_RTL8720C + void start_isr_item_(size_t index); + void arm_envelope_timer_(uint32_t duration_us); + void abort_stalled_chain_(); + void deliver_completion_(); + void wait_until_idle_(); + void arm_chain_(uint32_t send_times, uint32_t send_wait); + void update_carrier_(uint32_t carrier_frequency); + std::vector isr_data_; // owned copy of the frame; temp_ may be re-encoded mid-flight + float isr_mark_duty_{0.0f}; + float isr_space_duty_{0.0f}; + volatile size_t isr_index_{0}; + volatile uint32_t isr_repeats_left_{0}; + uint32_t isr_send_wait_{0}; + volatile uint32_t isr_wait_remaining_{0}; // remainder of a duration chained across one-shots + volatile bool isr_in_gap_{false}; + volatile bool transmitting_{false}; + bool non_blocking_{false}; + bool complete_pending_{false}; + bool stall_aborted_{false}; // this transmission ended via abort; blocks warning clear +#endif #if defined(USE_ESP32) && SOC_RMT_SUPPORTED void configure_rmt_(); diff --git a/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp b/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp index b7078b9d69..9f629168f2 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp @@ -5,31 +5,49 @@ // clang-tidy cannot parse the Realtek SDK headers pulled in via ArduinoPrivate.h #if defined(USE_RTL87XX) && !defined(CLANG_TIDY) -// ArduinoPrivate.h = Arduino.h + the SDK's mbed HAL (pwmout etc.) with the core's fixes for +// ArduinoPrivate.h = Arduino.h + the SDK's mbed HAL (pwmout, gtimer) with the core's fixes for // type-name collisions between the two (e.g. PinMode) #include +#ifndef USE_LIBRETINY_VARIANT_RTL8720C #include #include +#endif namespace esphome::remote_transmitter { static const char *const TAG = "remote_transmitter"; -// The carrier is generated by the PWM peripheral instead of bit-banging the pin: software carrier -// generation requires disabling interrupts for the whole frame, but this core's micros() is derived -// from the FreeRTOS tick and freezes while interrupts are off, so the timing loop never advances and -// the watchdog resets the chip. With hardware PWM, software only times the mark/space envelope and -// interrupts can stay enabled. -// -// The PWM is driven through the SDK's pwmout HAL directly rather than the Arduino wiring layer: -// changing the carrier frequency via the wiring requires a GPIO/PWM pin mode round-trip, which -// use-after-frees the core's per-pin state (pinRemoveMode() frees without nulling) and corrupts the -// heap. pwmout_period_us() changes the frequency with no mode transitions. +// PWM peripheral carrier, envelope paced by a gtimer interrupt chain. Bit-banging would need +// interrupts disabled for the whole frame, but this core's micros() derives from the FreeRTOS +// tick and freezes then. The SDK pwmout HAL is driven directly: the Arduino wiring layer's +// GPIO/PWM mode round-trip use-after-frees LibreTiny's per-pin state. + +#ifdef USE_LIBRETINY_VARIANT_RTL8720C +static constexpr uint32_t ENVELOPE_TIMER_ID = TIMER6; // GTimer7 +// Margin past a transmission's expected duration before the chain is declared stalled +static constexpr uint32_t STALL_MARGIN_MS = 1000; +// Longest single one-shot armed; longer durations are chained (ROM us->tick headroom unverified) +static constexpr uint32_t MAX_ONE_SHOT_US = 50000; + +// Shared envelope timer: a second gtimer_init on the same id fails silently, so all +// instances serialize on s_active_transmitter +// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) +static uint8_t s_pwm_tick_sources[] = {GTimer1, GTimer2, GTimer3, GTimer4, GTimer5, GTimer6, 0xff}; +static gtimer_t s_envelope_timer; +static bool s_envelope_timer_ready = false; +static RemoteTransmitterComponent *volatile s_active_transmitter = nullptr; +// Deadline for the in-flight transmission (millis-based); only touched from the main task +static uint32_t s_expected_end_ms = 0; +// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) + +static void IRAM_ATTR envelope_timer_isr(uint32_t arg) { + reinterpret_cast(arg)->advance_envelope_isr(); +} +#endif // USE_LIBRETINY_VARIANT_RTL8720C void RemoteTransmitterComponent::setup() { - // Deliberately no pin_->setup(): registering the pin as GPIO claims it in the SDK's pin - // management, and the pad is then never handed over to the PWM peripheral -- pwmout_init() - // must own the pin from the start. + // no pin_->setup(): a GPIO claim in the SDK's pin management blocks pwmout_init from + // owning the pad PinInfo *info = pinInfo(this->pin_->get_pin()); if (info == nullptr || !pinSupported(info, PIN_PWM)) { // checked here because the AmebaZ (RTL8710B) SDK does not report PWM init failure @@ -40,7 +58,7 @@ void RemoteTransmitterComponent::setup() { auto *pwm = new pwmout_t(); this->pwm_ = pwm; pwmout_init(pwm, static_cast(info->gpio)); -#if LT_RTL8720C +#ifdef USE_LIBRETINY_VARIANT_RTL8720C // only the AmebaZ2 SDK's pwmout_s reports init success if (!pwm->is_init) { ESP_LOGE(TAG, "PWM init failed on pin %u", this->pin_->get_pin()); @@ -49,9 +67,19 @@ void RemoteTransmitterComponent::setup() { this->mark_failed(); return; } + // Shrink the PWM tick-source pool before the period claim below so GTimer7 stays free + // for the envelope; pwmout_init just registered the full pool. + hal_pwm_comm_tick_source_list(s_pwm_tick_sources); #endif pwmout_period_us(pwm, 26); // placeholder; the real carrier period is set per transmission pwmout_write(pwm, this->pin_->is_inverted() ? 1.0f : 0.0f); +#ifdef USE_LIBRETINY_VARIANT_RTL8720C + if (!s_envelope_timer_ready) { + gtimer_init(&s_envelope_timer, ENVELOPE_TIMER_ID); + s_envelope_timer_ready = true; + } + this->disable_loop(); // loop() is only needed while a non-blocking completion is pending +#endif } void RemoteTransmitterComponent::dump_config() { @@ -59,9 +87,224 @@ void RemoteTransmitterComponent::dump_config() { "Remote Transmitter:\n" " Carrier Duty: %u%%", this->carrier_duty_percent_); +#ifdef USE_LIBRETINY_VARIANT_RTL8720C + ESP_LOGCONFIG(TAG, " Non-blocking: %s", YESNO(this->non_blocking_)); +#endif LOG_PIN(" Pin: ", this->pin_); } +void RemoteTransmitterComponent::digital_write(bool value) { + if (this->pwm_ == nullptr) + return; +#ifdef USE_LIBRETINY_VARIANT_RTL8720C + // serialize behind an in-flight chain, matching the ESP32/RMT non-blocking behavior + this->wait_until_idle_(); +#endif + pwmout_write(static_cast(this->pwm_), (value != this->pin_->is_inverted()) ? 1.0f : 0.0f); +} + +#ifdef USE_LIBRETINY_VARIANT_RTL8720C +// Arms the shared envelope timer, chaining durations longer than MAX_ONE_SHOT_US. ISR-safe. +void IRAM_ATTR RemoteTransmitterComponent::arm_envelope_timer_(uint32_t duration_us) { + // clamp to 1us (a zero-length one-shot never fires); the remainder must not underflow + const uint32_t chunk = std::max(uint32_t(1), std::min(duration_us, MAX_ONE_SHOT_US)); + this->isr_wait_remaining_ = duration_us > chunk ? duration_us - chunk : 0; + gtimer_start_one_shout(&s_envelope_timer, chunk, (void *) envelope_timer_isr, (uint32_t) this); +} + +// Aborts a chain that stopped advancing: stop the timer, idle the pin, release the token. +// Every step is a no-op if the chain completed meanwhile. Task context only. +void RemoteTransmitterComponent::abort_stalled_chain_() { + // cleared first so a straggler one-shot bails at the ISR entry check + this->transmitting_ = false; + gtimer_stop(&s_envelope_timer); + pwmout_write(static_cast(this->pwm_), this->isr_space_duty_); + s_active_transmitter = nullptr; + this->stall_aborted_ = true; + this->status_set_warning("envelope timer stalled"); + ESP_LOGE(TAG, "Envelope timer stalled; transmission aborted"); + delay(1); // let any already-latched interrupt land while the chain state is safe +} + +// Delivers one deferred completion with its status bookkeeping +void RemoteTransmitterComponent::deliver_completion_() { + if (!this->stall_aborted_) + this->status_clear_warning(); + this->complete_pending_ = false; + this->complete_trigger_.trigger(); +} + +// Writes the duty for one envelope item and arms the timer for its duration. +// Runs in ISR context (and once from send_internal to kick the chain): no logging, no allocation. +void IRAM_ATTR RemoteTransmitterComponent::start_isr_item_(size_t index) { + const int32_t item = this->isr_data_[index]; + pwmout_write(static_cast(this->pwm_), item > 0 ? this->isr_mark_duty_ : this->isr_space_duty_); + this->arm_envelope_timer_(uint32_t(item > 0 ? item : -item)); +} + +void IRAM_ATTR RemoteTransmitterComponent::advance_envelope_isr() { + if (!this->transmitting_) + return; // chain was aborted; this is a stale one-shot that was already latched + if (this->isr_wait_remaining_ > 0) { + // continue a duration longer than one hardware one-shot + this->arm_envelope_timer_(this->isr_wait_remaining_); + return; + } + if (this->isr_in_gap_) { + // inter-repeat gap elapsed; restart the item chain + this->isr_in_gap_ = false; + this->isr_index_ = 0; + this->start_isr_item_(0); + return; + } + this->isr_index_++; + if (this->isr_index_ < this->isr_data_.size()) { + this->start_isr_item_(this->isr_index_); + return; + } + // end of one repetition + pwmout_write(static_cast(this->pwm_), this->isr_space_duty_); + if (this->isr_repeats_left_ > 1) { + this->isr_repeats_left_--; + this->isr_index_ = 0; + if (this->isr_send_wait_ > 0) { + this->isr_in_gap_ = true; + this->arm_envelope_timer_(this->isr_send_wait_); + } else { + this->start_isr_item_(0); + } + return; + } + this->transmitting_ = false; + s_active_transmitter = nullptr; +} + +// Waits until no chain is in flight, delivering any deferred completions; a completion +// automation may start a new send, so repeat until truly idle. Bounded by the stall deadline. +void RemoteTransmitterComponent::wait_until_idle_() { + while (true) { + while (true) { + // snapshot: the final ISR can clear the volatile pointer between a check and a use + auto *active = s_active_transmitter; + if (active == nullptr) + break; + if ((int32_t) (millis() - s_expected_end_ms) > 0) { + active->abort_stalled_chain_(); + break; + } + App.feed_wdt(); + delay(1); + } + if (!this->complete_pending_) + break; + this->deliver_completion_(); + } +} + +// Retunes the PWM period when the carrier changes; the ISR sets duty per item +void RemoteTransmitterComponent::update_carrier_(uint32_t carrier_frequency) { + if (carrier_frequency == 0 || carrier_frequency == this->current_carrier_frequency_) + return; + // round(1000000/freq), clamped so a bad lambda can't hand the SDK a zero period + const uint32_t period = std::max(uint32_t(1), (1000000UL + carrier_frequency / 2) / carrier_frequency); + pwmout_period_us(static_cast(this->pwm_), period); + this->current_carrier_frequency_ = carrier_frequency; +} + +// Stages the repeat schedule and stall deadline, then starts the interrupt chain +void RemoteTransmitterComponent::arm_chain_(uint32_t send_times, uint32_t send_wait) { + this->isr_repeats_left_ = send_times; + this->isr_send_wait_ = send_wait; + this->isr_index_ = 0; + this->isr_in_gap_ = false; + this->stall_aborted_ = false; + uint64_t frame_us = 0; + for (int32_t item : this->isr_data_) + frame_us += uint32_t(item > 0 ? item : -item); + const uint64_t total_us = frame_us * send_times + uint64_t(send_wait) * (send_times - 1); + s_expected_end_ms = millis() + uint32_t(total_us / 1000) + STALL_MARGIN_MS; + this->transmitting_ = true; + s_active_transmitter = this; + this->start_isr_item_(0); +} + +void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) { + if (this->pwm_ == nullptr) { + ESP_LOGW(TAG, "Cannot send: PWM not initialized"); + return; + } + this->wait_until_idle_(); + if (send_times == 0) { + // parity with the loop-based implementations: transmit nothing, but both triggers + // still fire so an on_complete-sequenced automation does not stall + this->transmit_trigger_.trigger(); + this->deliver_completion_(); + return; + } + ESP_LOGD(TAG, "Sending remote code"); + const uint32_t carrier_frequency = this->temp_.get_carrier_frequency(); + // unmodulated protocols (no carrier or 100% duty) drive the pin constantly during marks + float mark_duty = + (carrier_frequency > 0 && this->carrier_duty_percent_ < 100) ? this->carrier_duty_percent_ / 100.0f : 1.0f; + float space_duty = 0.0f; + if (this->pin_->is_inverted()) { + mark_duty = 1.0f - mark_duty; + space_duty = 1.0f; + } + this->update_carrier_(carrier_frequency); + // own copy: with non_blocking the caller may re-encode temp_ while this frame is in flight + this->isr_data_.assign(this->temp_.get_data().begin(), this->temp_.get_data().end()); + if (this->isr_data_.empty()) { + ESP_LOGW(TAG, "Empty data"); + this->transmit_trigger_.trigger(); + this->deliver_completion_(); + return; + } + this->isr_mark_duty_ = mark_duty; + this->isr_space_duty_ = space_duty; + // trigger first: the deadline computed in arm_chain_ must not be charged for user code + this->transmit_trigger_.trigger(); + // the automation may have started a send on another instance; let it finish before + // claiming the shared timer (a same-instance send remains unsupported here) + this->wait_until_idle_(); + this->arm_chain_(send_times, send_wait); + if (this->non_blocking_) { + this->complete_pending_ = true; + this->enable_loop(); + return; + } + // blocking mode: wait out the chain, bounded by the stall deadline + while (this->transmitting_) { + if ((int32_t) (millis() - s_expected_end_ms) > 0) { + this->abort_stalled_chain_(); + break; + } + App.feed_wdt(); + delay(1); + } + this->deliver_completion_(); +} + +void RemoteTransmitterComponent::loop() { + if (!this->complete_pending_) { + this->disable_loop(); + return; + } + if (this->transmitting_) { + // non-blocking stall recovery: without this, a dead chain would leave the carrier + // driven and on_complete unfired until the next send happened to abort it + if ((int32_t) (millis() - s_expected_end_ms) <= 0) + return; + this->abort_stalled_chain_(); + } + // release the loop before user code runs: the automation may start a new non-blocking + // send, and its enable_loop() must be the last writer or its completion would strand + this->disable_loop(); + this->deliver_completion_(); +} + +#else // !USE_LIBRETINY_VARIANT_RTL8720C -- AmebaZ (RTL8710B): spin-based envelope, per-frame priority boost + void RemoteTransmitterComponent::await_target_time_() { const uint32_t current_time = micros(); if (this->target_time_ == 0) { @@ -72,15 +315,8 @@ void RemoteTransmitterComponent::await_target_time_() { } } -void RemoteTransmitterComponent::digital_write(bool value) { - if (this->pwm_ == nullptr) - return; - pwmout_write(static_cast(this->pwm_), (value != this->pin_->is_inverted()) ? 1.0f : 0.0f); -} - void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) { - auto *pwm = static_cast(this->pwm_); - if (pwm == nullptr) { + if (this->pwm_ == nullptr) { ESP_LOGW(TAG, "Cannot send: PWM not initialized"); return; } @@ -94,6 +330,7 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen mark_duty = 1.0f - mark_duty; space_duty = 1.0f; } + auto *pwm = static_cast(this->pwm_); if (carrier_frequency > 0 && carrier_frequency != this->current_carrier_frequency_) { // round(1000000/freq), clamped like the bit-bang path so a bad lambda can't hand the SDK a zero period const uint32_t period = std::max(uint32_t(1), (1000000UL + carrier_frequency / 2) / carrier_frequency); @@ -132,6 +369,8 @@ void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t sen this->complete_trigger_.trigger(); } +#endif // USE_LIBRETINY_VARIANT_RTL8720C + } // namespace esphome::remote_transmitter #endif // USE_RTL87XX && !CLANG_TIDY diff --git a/tests/component_tests/remote_transmitter/__init__.py b/tests/component_tests/remote_transmitter/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/remote_transmitter/test_non_blocking_gate.py b/tests/component_tests/remote_transmitter/test_non_blocking_gate.py new file mode 100644 index 0000000000..f843f1e84f --- /dev/null +++ b/tests/component_tests/remote_transmitter/test_non_blocking_gate.py @@ -0,0 +1,42 @@ +"""non_blocking is family-gated at config validation; the CI build boards never compile +the ISR paths, so this gate is the only CI-reachable coverage for the platform matrix.""" + +import pytest + +from esphome.components.libretiny.const import ( + FAMILY_RTL8710B, + FAMILY_RTL8720C, + KEY_FAMILY, + KEY_LIBRETINY, +) +from esphome.components.remote_transmitter import _validate_non_blocking_platform +import esphome.config_validation as cv +from esphome.const import PlatformFramework +from esphome.core import CORE + +from ..types import SetCoreConfigCallable + + +@pytest.mark.parametrize( + ("platform_framework", "family", "accepted"), + [ + (PlatformFramework.ESP32_IDF, None, True), + (PlatformFramework.RTL87XX_ARDUINO, FAMILY_RTL8720C, True), + (PlatformFramework.RTL87XX_ARDUINO, FAMILY_RTL8710B, False), + (PlatformFramework.ESP8266_ARDUINO, None, False), + ], +) +def test_non_blocking_platform_gate( + set_core_config: SetCoreConfigCallable, + platform_framework: PlatformFramework, + family: str | None, + accepted: bool, +) -> None: + set_core_config(platform_framework) + if family is not None: + CORE.data[KEY_LIBRETINY] = {KEY_FAMILY: family} + if accepted: + assert _validate_non_blocking_platform(True) is True + else: + with pytest.raises(cv.Invalid, match="non_blocking is only supported on"): + _validate_non_blocking_platform(True) diff --git a/tests/components/remote_transmitter/test.rtl87xx-ard.yaml b/tests/components/remote_transmitter/test.rtl87xx-ard.yaml index 769adbdf5c..74caa24cdd 100644 --- a/tests/components/remote_transmitter/test.rtl87xx-ard.yaml +++ b/tests/components/remote_transmitter/test.rtl87xx-ard.yaml @@ -2,6 +2,7 @@ remote_transmitter: id: xmitr pin: GPIO12 carrier_duty_percent: 50% + # non_blocking is rtl8720c-only; the CI board is an RTL8710B packages: buttons: !include common-buttons.yaml From 612d58ec37e7fc565bf6fef3214247a4ff0f5366 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Aug 2026 23:11:26 -0500 Subject: [PATCH 11/30] [http_request] Default watchdog_timeout from timeout on ESP32 (#18732) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- esphome/components/http_request/__init__.py | 34 ++++++++++++++- .../http_request/http_request_idf.cpp | 3 +- .../component_tests/http_request/__init__.py | 0 .../config/test_esp32_default.yaml | 12 ++++++ .../config/test_esp32_explicit.yaml | 13 ++++++ .../config/test_esp32_platform_wider.yaml | 13 ++++++ .../http_request/config/test_esp32_stock.yaml | 11 +++++ .../http_request/config/test_esp8266.yaml | 13 ++++++ .../http_request/config/test_rp2040.yaml | 13 ++++++ .../component_tests/http_request/test_init.py | 42 +++++++++++++++++++ 10 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 tests/component_tests/http_request/__init__.py create mode 100644 tests/component_tests/http_request/config/test_esp32_default.yaml create mode 100644 tests/component_tests/http_request/config/test_esp32_explicit.yaml create mode 100644 tests/component_tests/http_request/config/test_esp32_platform_wider.yaml create mode 100644 tests/component_tests/http_request/config/test_esp32_stock.yaml create mode 100644 tests/component_tests/http_request/config/test_esp8266.yaml create mode 100644 tests/component_tests/http_request/config/test_rp2040.yaml create mode 100644 tests/component_tests/http_request/test_init.py diff --git a/esphome/components/http_request/__init__.py b/esphome/components/http_request/__init__.py index 2abf097aec..de35d52a40 100644 --- a/esphome/components/http_request/__init__.py +++ b/esphome/components/http_request/__init__.py @@ -17,12 +17,14 @@ from esphome.const import ( CONF_TIMEOUT, CONF_URL, CONF_WATCHDOG_TIMEOUT, + PLATFORM_ESP32, PLATFORM_HOST, PlatformFramework, __version__, ) -from esphome.core import CORE, ID, Lambda +from esphome.core import CORE, ID, Lambda, TimePeriodMilliseconds from esphome.cpp_generator import MockObj, TemplateArgsType +import esphome.final_validate as fv from esphome.helpers import IS_MACOS from esphome.types import ConfigType @@ -94,6 +96,34 @@ def validate_ssl_verification(config: ConfigType) -> ConfigType: return config +# esp_http_client_open() runs DNS, TCP connect and the TLS handshake with no +# watchdog feed in between; each can take up to `timeout` on ESP-IDF. +WATCHDOG_TIMEOUT_MULTIPLIER = 3 +# Headroom over the exact worst case so a fully stalled open does not land on +# the watchdog deadline. +WATCHDOG_TIMEOUT_MARGIN_MS = 1000 + + +def default_watchdog_timeout(config: ConfigType) -> None: + """Arm the request watchdog on ESP32 when the user did not set it. + + The default never goes below the platform task watchdog, so a user who + widened `esp32.watchdog_timeout` keeps that window during requests. + """ + if not CORE.is_esp32 or CONF_WATCHDOG_TIMEOUT in config: + return + derived_ms = ( + config[CONF_TIMEOUT].total_milliseconds * WATCHDOG_TIMEOUT_MULTIPLIER + + WATCHDOG_TIMEOUT_MARGIN_MS + ) + platform_ms = fv.full_config.get()[PLATFORM_ESP32][ + CONF_WATCHDOG_TIMEOUT + ].total_milliseconds + config[CONF_WATCHDOG_TIMEOUT] = TimePeriodMilliseconds( + milliseconds=max(derived_ms, platform_ms) + ) + + def _declare_request_class(value: Any) -> ID: if CORE.is_host: return cv.declare_id(HttpRequestHost)(value) @@ -153,6 +183,8 @@ CONFIG_SCHEMA = cv.All( validate_ssl_verification, ) +FINAL_VALIDATE_SCHEMA = default_watchdog_timeout + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index 470ed332f1..10313be89d 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -142,12 +142,13 @@ std::shared_ptr HttpRequestIDF::perform(const std::string &url, c const char *buf = body.c_str(); while (write_left > 0) { int written = esp_http_client_write(client, buf + write_index, write_left); - if (written < 0) { + if (written <= 0) { err = ESP_FAIL; break; } write_left -= written; write_index += written; + container->feed_wdt(); } } diff --git a/tests/component_tests/http_request/__init__.py b/tests/component_tests/http_request/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/http_request/config/test_esp32_default.yaml b/tests/component_tests/http_request/config/test_esp32_default.yaml new file mode 100644 index 0000000000..86744dcb11 --- /dev/null +++ b/tests/component_tests/http_request/config/test_esp32_default.yaml @@ -0,0 +1,12 @@ +esphome: + name: test + +esp32: + board: nodemcu-32s + +wifi: + ssid: test + password: testtest + +http_request: + timeout: 10s diff --git a/tests/component_tests/http_request/config/test_esp32_explicit.yaml b/tests/component_tests/http_request/config/test_esp32_explicit.yaml new file mode 100644 index 0000000000..e0d0074caa --- /dev/null +++ b/tests/component_tests/http_request/config/test_esp32_explicit.yaml @@ -0,0 +1,13 @@ +esphome: + name: test + +esp32: + board: nodemcu-32s + +wifi: + ssid: test + password: testtest + +http_request: + timeout: 10s + watchdog_timeout: 20s diff --git a/tests/component_tests/http_request/config/test_esp32_platform_wider.yaml b/tests/component_tests/http_request/config/test_esp32_platform_wider.yaml new file mode 100644 index 0000000000..77a85da2ff --- /dev/null +++ b/tests/component_tests/http_request/config/test_esp32_platform_wider.yaml @@ -0,0 +1,13 @@ +esphome: + name: test + +esp32: + board: nodemcu-32s + watchdog_timeout: 60s + +wifi: + ssid: test + password: testtest + +http_request: + timeout: 10s diff --git a/tests/component_tests/http_request/config/test_esp32_stock.yaml b/tests/component_tests/http_request/config/test_esp32_stock.yaml new file mode 100644 index 0000000000..70d2701466 --- /dev/null +++ b/tests/component_tests/http_request/config/test_esp32_stock.yaml @@ -0,0 +1,11 @@ +esphome: + name: test + +esp32: + board: nodemcu-32s + +wifi: + ssid: test + password: testtest + +http_request: diff --git a/tests/component_tests/http_request/config/test_esp8266.yaml b/tests/component_tests/http_request/config/test_esp8266.yaml new file mode 100644 index 0000000000..d0698dc57e --- /dev/null +++ b/tests/component_tests/http_request/config/test_esp8266.yaml @@ -0,0 +1,13 @@ +esphome: + name: test + +esp8266: + board: d1_mini + +wifi: + ssid: test + password: testtest + +http_request: + timeout: 10s + verify_ssl: false diff --git a/tests/component_tests/http_request/config/test_rp2040.yaml b/tests/component_tests/http_request/config/test_rp2040.yaml new file mode 100644 index 0000000000..030736c30d --- /dev/null +++ b/tests/component_tests/http_request/config/test_rp2040.yaml @@ -0,0 +1,13 @@ +esphome: + name: test + +rp2: + board: rpipicow + +wifi: + ssid: test + password: testtest + +http_request: + timeout: 10s + verify_ssl: false diff --git a/tests/component_tests/http_request/test_init.py b/tests/component_tests/http_request/test_init.py new file mode 100644 index 0000000000..446c4acbd0 --- /dev/null +++ b/tests/component_tests/http_request/test_init.py @@ -0,0 +1,42 @@ +"""Tests for the http_request watchdog timeout default.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.config import read_config +from esphome.const import CONF_WATCHDOG_TIMEOUT +from esphome.core import CORE, TimePeriodMilliseconds + + +@pytest.mark.parametrize( + ("yaml_file", "expected_ms"), + [ + # stock 4.5s timeout: 3 x 4.5s plus 1s margin + ("test_esp32_stock.yaml", 14500), + # 3 x 10s plus 1s margin + ("test_esp32_default.yaml", 31000), + # esp32.watchdog_timeout: 60s is wider than the derived value and wins + ("test_esp32_platform_wider.yaml", 60000), + # explicit value is kept as is + ("test_esp32_explicit.yaml", 20000), + ], +) +def test_esp32_watchdog_timeout( + component_config_path: Callable[[str], Path], yaml_file: str, expected_ms: int +) -> None: + CORE.config_path = component_config_path(yaml_file) + config = read_config({}) + assert config["http_request"][CONF_WATCHDOG_TIMEOUT] == TimePeriodMilliseconds( + milliseconds=expected_ms + ) + + +@pytest.mark.parametrize("yaml_file", ["test_esp8266.yaml", "test_rp2040.yaml"]) +def test_other_platforms_leave_watchdog_unset( + component_config_path: Callable[[str], Path], yaml_file: str +) -> None: + CORE.config_path = component_config_path(yaml_file) + config = read_config({}) + assert CONF_WATCHDOG_TIMEOUT not in config["http_request"] From 9baff7652074031d27ba4c547bfee6250e36151a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Aug 2026 23:29:14 -0500 Subject: [PATCH 12/30] [api] Treat homeassistant.event variables as lambdas (#18759) --- esphome/components/api/__init__.py | 45 +++++++++++-- esphome/config_validation.py | 35 ++++++++++- esphome/core/__init__.py | 3 +- .../api/test_homeassistant_variables.py | 63 +++++++++++++++++++ .../api/test_homeassistant_variables.yaml | 32 ++++++++++ tests/components/api/common-base.yaml | 8 +++ tests/components/homeassistant/common.yaml | 4 +- tests/unit_tests/test_config_validation.py | 46 ++++++++++++++ 8 files changed, 228 insertions(+), 8 deletions(-) create mode 100644 tests/component_tests/api/test_homeassistant_variables.py create mode 100644 tests/component_tests/api/test_homeassistant_variables.yaml diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index a10bfd3418..2e891a9663 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -1,4 +1,5 @@ import logging +import re from typing import Any from esphome import automation @@ -499,6 +500,40 @@ async def to_code(config: ConfigType) -> None: KEY_VALUE_SCHEMA = cv.Schema({cv.string: cv.templatable(cv.string_strict)}) +_ID_CALL_PROG = re.compile(r"\bid\s*\(") + + +# Remove before 2027.3.0: untagged strings that look like lambda source keep +# being compiled as lambdas during the deprecation window +def _coerce_implicit_lambda(value: Any) -> Any: + if not isinstance(value, str): + return value + if cv.looks_like_returning_lambda(value): + _LOGGER.warning( + "[api] The 'variables' value '%s' looks like a lambda but is " + "missing the !lambda tag. It is compiled as a lambda for now but " + "will be sent as literal text from 2027.3.0. Add !lambda to keep " + "it evaluated; literal text belongs under 'data:'.", + value, + ) + # cv.templatable runs returning_lambda on the coerced Lambda + return cv.lambda_(value) + if _ID_CALL_PROG.search(value): + # lambda source without a return: issue 5394's mistake class + _LOGGER.warning( + "[api] The 'variables' value '%s' is sent as literal text; wrap " + "it in !lambda 'return ...;' to evaluate it instead.", + value, + ) + return value + + +# Static strings or !lambda values. cv.templatable stays introspectable for +# schema tooling; removing the shim leaves KEY_VALUE_SCHEMA. +VARIABLES_SCHEMA = cv.Schema( + {cv.string: cv.All(_coerce_implicit_lambda, cv.templatable(cv.string_strict))} +) + def _validate_response_config(config: ConfigType) -> ConfigType: # Validate dependencies: @@ -535,9 +570,7 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All( ), cv.Optional(CONF_DATA, default={}): KEY_VALUE_SCHEMA, cv.Optional(CONF_DATA_TEMPLATE, default={}): KEY_VALUE_SCHEMA, - cv.Optional(CONF_VARIABLES, default={}): cv.Schema( - {cv.string: cv.returning_lambda} - ), + cv.Optional(CONF_VARIABLES, default={}): VARIABLES_SCHEMA, cv.Optional(CONF_RESPONSE_TEMPLATE): cv.templatable(cv.string), cv.Optional(CONF_CAPTURE_RESPONSE, default=False): cv.boolean, cv.Optional(CONF_ON_SUCCESS): automation.validate_automation(single=True), @@ -598,6 +631,8 @@ async def homeassistant_service_to_code( cg.add(var.init_variables(len(config[CONF_VARIABLES]))) for key, value in config[CONF_VARIABLES].items(): templ = await cg.templatable(value, args, None) + if isinstance(templ, str): + templ = cg.FlashStringLiteral(templ) cg.add(var.add_variable(cg.FlashStringLiteral(key), templ)) if on_error := config.get(CONF_ON_ERROR): @@ -652,7 +687,7 @@ HOMEASSISTANT_EVENT_ACTION_SCHEMA = cv.Schema( cv.Required(CONF_EVENT): validate_homeassistant_event, cv.Optional(CONF_DATA, default={}): KEY_VALUE_SCHEMA, cv.Optional(CONF_DATA_TEMPLATE, default={}): KEY_VALUE_SCHEMA, - cv.Optional(CONF_VARIABLES, default={}): KEY_VALUE_SCHEMA, + cv.Optional(CONF_VARIABLES, default={}): VARIABLES_SCHEMA, } ) @@ -698,6 +733,8 @@ async def homeassistant_event_to_code( cg.add(var.init_variables(len(config[CONF_VARIABLES]))) for key, value in config[CONF_VARIABLES].items(): templ = await cg.templatable(value, args, None) + if isinstance(templ, str): + templ = cg.FlashStringLiteral(templ) cg.add(var.add_variable(cg.FlashStringLiteral(key), templ)) return var diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 09962e8c95..904cbd1919 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1882,13 +1882,46 @@ def lambda_(value): return value +# 'return' at a statement boundary; only consulted when the source has no +# semicolon, so ';' is not a boundary. Migration use only, see +# looks_like_returning_lambda. +LAMBDA_RETURN_STATEMENT_PROG = re.compile(r"(?:^|[:{})\n])\s*return\b") +LAMBDA_RETURN_KEYWORD_PROG = re.compile(r"\breturn\b") +# RESERVED_IDS subset that can begin a return expression; 'this'/'true' would +# promote prose and infix 'and'/'or' cannot start an expression. +_CPP_LEADING_WORD_OPERATORS = "not|new|sizeof|delete" +# Two or more plain words: prose, not C++. A single word is indistinguishable +# from 'return x'. Migration use only, see looks_like_returning_lambda. +LAMBDA_PROSE_TAIL_PROG = re.compile( + rf"(?!(?:{_CPP_LEADING_WORD_OPERATORS})\b)[A-Za-z']+(?:,?\s+[A-Za-z']+)+[.!?]?" +) + + +def looks_like_returning_lambda(value: str) -> bool: + """Check whether a string looks like C++ lambda source: a semicolon means + code, so any return keyword counts; without one, a boundary return whose + tail does not read as prose is a return statement missing its semicolon. + + For migrating deprecated implicit lambdas only; new validators must + require an explicit !lambda tag instead of guessing. + """ + src = Lambda.comment_remover(value) + if ";" in src: + return LAMBDA_RETURN_KEYWORD_PROG.search(src) is not None + for match in LAMBDA_RETURN_STATEMENT_PROG.finditer(src): + tail = src[match.end() :].split("\n", 1)[0].strip() + if not LAMBDA_PROSE_TAIL_PROG.fullmatch(tail): + return True + return False + + def returning_lambda(value): """Coerce this configuration option to a lambda. Additionally, make sure the lambda returns something. """ value = lambda_(value) - if "return" not in value.value: + if LAMBDA_RETURN_KEYWORD_PROG.search(Lambda.comment_remover(value.value)) is None: raise Invalid( "Lambda doesn't contain a 'return' statement, but the lambda " "is expected to return a value. \n" diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 2ec2a08e83..77efc91bef 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -339,7 +339,8 @@ class Lambda: self._requires_ids = None # https://stackoverflow.com/a/241506/229052 - def comment_remover(self, text): + @staticmethod + def comment_remover(text): def replacer(match): s = match.group(0) if s.startswith("/"): diff --git a/tests/component_tests/api/test_homeassistant_variables.py b/tests/component_tests/api/test_homeassistant_variables.py new file mode 100644 index 0000000000..48e53d8f4c --- /dev/null +++ b/tests/component_tests/api/test_homeassistant_variables.py @@ -0,0 +1,63 @@ +"""Tests for variables handling in homeassistant.event and homeassistant.action.""" + +from collections.abc import Callable +import logging +from pathlib import Path + +import pytest + +CONFIG = "tests/component_tests/api/test_homeassistant_variables.yaml" + + +def test_plain_string_with_return_is_compiled_as_lambda_with_warning( + generate_main: Callable[[str | Path], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """A plain string with a return statement compiles as a lambda and warns.""" + with caplog.at_level(logging.WARNING): + main_cpp = generate_main(CONFIG) + + assert main_cpp.count('add_variable(ESPHOME_F("lambda_var"), []() {') == 2 + assert "return millis();" in main_cpp + # The source text must not be sent as a static string value. + assert '"return millis();"' not in main_cpp + assert "missing the !lambda tag" in caplog.text + + +def test_static_string_is_kept_as_static_value( + generate_main: Callable[[str | Path], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """A static string stays static, PROGMEM wrapped, with no warning.""" + with caplog.at_level(logging.WARNING): + main_cpp = generate_main(CONFIG) + + assert ( + main_cpp.count( + 'add_variable(ESPHOME_F("static_var"), ESPHOME_F("static value"));' + ) + == 2 + ) + assert "static value" not in caplog.text + + +def test_static_id_value_stays_literal_with_hint( + generate_main: Callable[[str | Path], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Lambda source without a return stays literal text but warns.""" + with caplog.at_level(logging.WARNING): + main_cpp = generate_main(CONFIG) + + assert 'ESPHOME_F("id(test_sensor).state")' in main_cpp + assert "sent as literal text" in caplog.text + + +def test_explicit_lambda_tag_is_compiled_as_lambda( + generate_main: Callable[[str | Path], str], +) -> None: + """A !lambda value keeps working unchanged.""" + main_cpp = generate_main(CONFIG) + + assert 'add_variable(ESPHOME_F("tagged_var"), []() {' in main_cpp + assert "return App.get_name();" in main_cpp diff --git a/tests/component_tests/api/test_homeassistant_variables.yaml b/tests/component_tests/api/test_homeassistant_variables.yaml new file mode 100644 index 0000000000..e1ec07cc74 --- /dev/null +++ b/tests/component_tests/api/test_homeassistant_variables.yaml @@ -0,0 +1,32 @@ +esphome: + name: test + on_boot: + then: + # Plain strings with a return statement compile as lambdas + - homeassistant.event: + event: esphome.test_event + data_template: + message: "{{ lambda_var }} {{ static_var }} {{ tagged_var }}" + variables: + lambda_var: |- + return millis(); + static_var: static value + tagged_var: !lambda return App.get_name(); + hint_var: id(test_sensor).state + - homeassistant.action: + action: notify.notify + data_template: + message: "{{ lambda_var }} {{ static_var }}" + variables: + lambda_var: |- + return millis(); + static_var: static value + +esp32: + board: esp32dev + +wifi: + ssid: SomeNetwork + password: SomePassword + +api: diff --git a/tests/components/api/common-base.yaml b/tests/components/api/common-base.yaml index d7470ee4b3..c9eb200471 100644 --- a/tests/components/api/common-base.yaml +++ b/tests/components/api/common-base.yaml @@ -9,6 +9,14 @@ esphome: event: esphome.button_pressed data: message: Button was pressed + - homeassistant.event: + event: esphome.button_pressed_with_variables + data_template: + message: Button {{ button_name }} ({{ button_index }}) was pressed from {{ button_source }} + variables: + button_name: !lambda 'return std::string("test_button");' + button_index: !lambda 'return 1;' + button_source: static_value - homeassistant.action: action: notify.html5 data: diff --git a/tests/components/homeassistant/common.yaml b/tests/components/homeassistant/common.yaml index 71a7ac65c2..1099f7ea85 100644 --- a/tests/components/homeassistant/common.yaml +++ b/tests/components/homeassistant/common.yaml @@ -12,7 +12,7 @@ esphome: data_template: message: The humidity is {{ my_variable }}%. variables: - my_variable: "return id(ha_hello_world_temperature).state;" + my_variable: !lambda "return id(ha_hello_world_temperature).state;" - homeassistant.action: action: notify.html5 data: @@ -24,7 +24,7 @@ esphome: data_template: message: The humidity is {{ my_variable }}%. variables: - my_variable: "return id(ha_hello_world_temperature).state;" + my_variable: !lambda "return id(ha_hello_world_temperature).state;" wifi: ssid: MySSID diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 0f927a6513..457b9d017b 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -2565,6 +2565,52 @@ def test_returning_lambda_no_return() -> None: cv.returning_lambda(Lambda("int x = 5;")) +def test_returning_lambda_return_only_in_comment() -> None: + with pytest.raises(Invalid, match="return statement"): + cv.returning_lambda(Lambda("// return 5;\nint x = 5;")) + + +def test_returning_lambda_missing_semicolon_is_accepted() -> None: + """A forgotten semicolon is left for the C++ compiler to report.""" + assert isinstance(cv.returning_lambda(Lambda("return x")), Lambda) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("return 5;", True), + ("if (x) { return x; } return 0;", True), + ("if (x) return 1; else return 0;", True), + ("switch (x) { case 0: return 1; }", True), + # a semicolon means code: any return keyword counts + ("return not x;", True), + ("return a and b;", True), + ("please return the sensor; then wait", True), + # a forgotten semicolon is still lambda source; the compiler reports it + ("return id(x).state", True), + ("return x", True), + ("return 5", True), + ("return not x", True), + # accepted: a one-word tail is indistinguishable from 'return x' + ("return soon", True), + ("Alert: return home", True), + ("static value", False), + ("no returns here", False), + ("the_return_value", False), + # without a semicolon, prose is not lambda source + ("please return the item", False), + ("return to sender", False), + ("return a and b", False), + # return only inside a comment is not a return statement + ("// return 5;\nint x = 5;", False), + ("/* return 5; */ int x = 5;", False), + ("return 5; // done", True), + ], +) +def test_looks_like_returning_lambda(value: str, expected: bool) -> None: + assert cv.looks_like_returning_lambda(value) is expected + + # --------------------------------------------------------------------------- # dimensions # --------------------------------------------------------------------------- From e37a540fb729edfd15fbd32281b7ccf53ecd74cb Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 25 Aug 2026 23:59:37 -0500 Subject: [PATCH 13/30] [esp32] Apply custom eFuse MAC as base MAC for all interfaces (#18452) --- esphome/components/esp32/core.cpp | 8 ++++++ esphome/components/esp32/helpers.cpp | 25 +++++++++++++------ .../wifi/wifi_component_esp_idf.cpp | 5 ---- esphome/core/helpers.h | 5 ++++ 4 files changed, 30 insertions(+), 13 deletions(-) diff --git a/esphome/components/esp32/core.cpp b/esphome/components/esp32/core.cpp index 098a59937a..a6916fe739 100644 --- a/esphome/components/esp32/core.cpp +++ b/esphome/components/esp32/core.cpp @@ -2,6 +2,7 @@ #include "esphome/core/application.h" #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #include "preferences.h" #include #include @@ -29,6 +30,13 @@ void loop_task(void *pv_params) { } extern "C" void app_main() { + // Apply the custom eFuse MAC (if burned and valid) as the base MAC before any + // interface (Wi-Fi, Ethernet, Bluetooth, 802.15.4) derives its address from it. + // The logger does not exist yet, so only log-free helpers may be used here. + uint8_t mac[MAC_ADDRESS_SIZE]; + if (get_custom_mac_address(mac)) { + set_mac_address(mac); + } initArduino(); esp32::setup_preferences(); #if CONFIG_FREERTOS_UNICORE diff --git a/esphome/components/esp32/helpers.cpp b/esphome/components/esp32/helpers.cpp index c2ff6cf34d..91b4241211 100644 --- a/esphome/components/esp32/helpers.cpp +++ b/esphome/components/esp32/helpers.cpp @@ -71,23 +71,32 @@ static bool read_valid_mac(uint8_t *mac, esp_err_t err) { return err == ESP_OK & static constexpr size_t MAC_ADDRESS_SIZE_BITS = MAC_ADDRESS_SIZE * 8; // 48 bits +// Must not use the ESPHome logger (may run before it exists, e.g. from app_main()). +bool get_custom_mac_address(uint8_t *mac) { + // has_custom_mac_address() checks the raw eFuse field, while the reads below select their + // method differently and may still fail (CRC), so the result must be validated again. + if (!has_custom_mac_address()) + return false; +#if defined(CONFIG_SOC_IEEE802154_SUPPORTED) + return read_valid_mac(mac, esp_efuse_read_field_blob(ESP_EFUSE_MAC_CUSTOM, mac, MAC_ADDRESS_SIZE_BITS)); +#else + return read_valid_mac(mac, esp_efuse_mac_get_custom(mac)); +#endif +} + void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter) + if (get_custom_mac_address(mac)) { + return; + } #if defined(CONFIG_SOC_IEEE802154_SUPPORTED) // When CONFIG_SOC_IEEE802154_SUPPORTED is defined, esp_efuse_mac_get_default // returns the 802.15.4 EUI-64 address, so we read directly from eFuse instead. - // Both paths already read raw eFuse bytes, so there is no CRC-bypass fallback + // This already reads raw eFuse bytes, so there is no CRC-bypass fallback // (unlike the non-IEEE802154 path where esp_efuse_mac_get_default does CRC checks). - if (has_custom_mac_address() && - read_valid_mac(mac, esp_efuse_read_field_blob(ESP_EFUSE_MAC_CUSTOM, mac, MAC_ADDRESS_SIZE_BITS))) { - return; - } if (read_valid_mac(mac, esp_efuse_read_field_blob(ESP_EFUSE_MAC_FACTORY, mac, MAC_ADDRESS_SIZE_BITS))) { return; } #else - if (has_custom_mac_address() && read_valid_mac(mac, esp_efuse_mac_get_custom(mac))) { - return; - } if (read_valid_mac(mac, esp_efuse_mac_get_default(mac))) { return; } diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 32d46887b6..06f0981020 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -140,11 +140,6 @@ void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, voi } void WiFiComponent::wifi_pre_setup_() { - uint8_t mac[MAC_ADDRESS_SIZE]; - if (has_custom_mac_address()) { - get_mac_address_raw(mac); - set_mac_address(mac); - } // Network interface setup handled by network component s_wifi_event_group = xEventGroupCreate(); if (s_wifi_event_group == nullptr) { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index e60316d4ee..9fdc088ecb 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -2089,6 +2089,11 @@ const char *get_mac_address_pretty_into_buffer(std::span Date: Wed, 26 Aug 2026 07:26:50 +0200 Subject: [PATCH 14/30] [climate_ir_lg] advanced vert. swing, setting temp. in heat/cool mode, jet mode decoding, other fixes + refactor (#10875) --- esphome/components/climate_ir_lg/climate.py | 3 + .../climate_ir_lg/climate_ir_lg.cpp | 330 ++++++++++++++---- .../components/climate_ir_lg/climate_ir_lg.h | 6 +- tests/components/climate_ir_lg/common.yaml | 3 + 4 files changed, 264 insertions(+), 78 deletions(-) diff --git a/esphome/components/climate_ir_lg/climate.py b/esphome/components/climate_ir_lg/climate.py index 48fd373b78..255fca9ad1 100644 --- a/esphome/components/climate_ir_lg/climate.py +++ b/esphome/components/climate_ir_lg/climate.py @@ -13,9 +13,11 @@ CONF_HEADER_LOW = "header_low" CONF_BIT_HIGH = "bit_high" CONF_BIT_ONE_LOW = "bit_one_low" CONF_BIT_ZERO_LOW = "bit_zero_low" +CONF_ADVANCED_COMMANDS_SUPPORT = "advanced_commands_support" CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(LgIrClimate).extend( { + cv.Optional(CONF_ADVANCED_COMMANDS_SUPPORT, default=False): cv.boolean, cv.Optional( CONF_HEADER_HIGH, default="8000us" ): cv.positive_time_period_microseconds, @@ -38,6 +40,7 @@ CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(LgIrClimate).extend( async def to_code(config: ConfigType) -> None: var = await climate_ir.new_climate_ir(config) + cg.add(var.set_advanced_commands_support(config[CONF_ADVANCED_COMMANDS_SUPPORT])) cg.add(var.set_header_high(config[CONF_HEADER_HIGH])) cg.add(var.set_header_low(config[CONF_HEADER_LOW])) cg.add(var.set_bit_high(config[CONF_BIT_HIGH])) diff --git a/esphome/components/climate_ir_lg/climate_ir_lg.cpp b/esphome/components/climate_ir_lg/climate_ir_lg.cpp index 588566dd9d..bb612eda7b 100644 --- a/esphome/components/climate_ir_lg/climate_ir_lg.cpp +++ b/esphome/components/climate_ir_lg/climate_ir_lg.cpp @@ -5,11 +5,85 @@ namespace esphome::climate_ir_lg { static const char *const TAG = "climate.climate_ir_lg"; -// Commands -const uint32_t COMMAND_MASK = 0xFF000; -const uint32_t COMMAND_OFF = 0xC0000; -const uint32_t COMMAND_SWING = 0x10000; +// All codes provided here are missing the checksum (last 4 bits) +// this checksum needs to be calculated before sending (look at `calc_checksum_()`) +const uint32_t LG_HEADER = 0x8800000; + +// Commands +const uint32_t COMMAND_HEADER_MASK = 0xFF000; +const uint32_t COMMAND_DATA_MASK = 0x00FF0; +const uint32_t CHECKSUM_MASK = 0xF; + +enum CommandBasic : uint32_t { + HEADER_BASIC = 0x10000, + BASIC_SWING_TOGGLE = 0x000, + + // JET MODE (only for cooling/drying/heating modes) + // For 30 minutes: max airflow (stronger than F5 aka FAN_MAX) + PO (min/min/max temperature respectively) + // After 30 minutes: F5 aka FAN_MAX + min/min/max temperature respectively + BASIC_JET = 0x080, +}; + +enum CommandSys : uint32_t { + HEADER_SYS = 0xC0000, + + COMMAND_OFF = 0x050, + + // Also known as 'auto-dry' + AUTO_CLEAN_ON = 0x0B0, + AUTO_CLEAN_OFF = 0x0C0, + + PURIFY_ON = 0x000, // From either OFF or Mode -> Purify + PURIFY_OFF = 0x080, // From Mode + Purify -> Mode + + QUIET_OUTDOOR_ON = 0xA60, + QUIET_OUTDOOR_OFF = 0xA70, + + // ENERGY CTRL (only in Cooling mode) + COOL_ENERG_CTRL_80 = 0x7D0, // 80% + COOL_ENERG_CTRL_60 = 0x7E0, // 60% + COOL_ENERG_CTRL_40 = 0x800, // 40% + COOL_ENERG_CTRL_OFF = 0x7F0, // OFF + + DISPLAY_KW = 0x460, + LIGHT_ON_OFF = 0x0A0, + + TEMP_UNIT_F = 0x170, + TEMP_UNIT_C = 0x160, +}; + +enum CommandAdvSwing : uint32_t { + HEADER_ADV_SWING = 0x13000, + + // Only 5 bits are relevant, I got 0x13952 once - not sure what is the 8th bit so ignoring that. + ADV_SWING_DATA_MASK = 0x1F0, + + // Commands for Advanced Vertical Control: Swing + 6 fixed positions + VERT_FIX_1 = 0x040, // Down + VERT_FIX_2 = 0x050, + VERT_FIX_3 = 0x060, + VERT_FIX_4 = 0x070, + VERT_FIX_5 = 0x080, + VERT_FIX_6 = 0x090, // Up + VERT_SWING_ON = 0x140, // Swing between 1 and 6 + VERT_SWING_OFF = 0x150, // Stops immediately + + // Commands for Advanced Horizontal Control: Swing (3 modes) + 5 fixed positions + HORI_FIX_1 = 0x0B0, // Left + HORI_FIX_2 = 0x0C0, + HORI_FIX_3 = 0x0D0, + HORI_FIX_4 = 0x0E0, + HORI_FIX_5 = 0x0F0, // Right + HORI_SWING_ON_LEFT = 0x100, // Swing between 1 and 3 + HORI_SWING_ON_RIGHT = 0x110, // Swing between 3 and 5 + HORI_SWING_ON_FULL = 0x160, // Swing between 1 and 5 + HORI_SWING_OFF = 0x170, // Stops immediately +}; + +// Following commands contain mode, fan speed and temperature + +// Modes const uint32_t COMMAND_ON_COOL = 0x00000; const uint32_t COMMAND_ON_DRY = 0x01000; const uint32_t COMMAND_ON_FAN_ONLY = 0x02000; @@ -23,11 +97,13 @@ const uint32_t COMMAND_AI = 0x0B000; const uint32_t COMMAND_HEAT = 0x0C000; // Fan speed -const uint32_t FAN_MASK = 0xF0; +const uint32_t FAN_SPEED_MASK = 0xF0; const uint32_t FAN_AUTO = 0x50; -const uint32_t FAN_MIN = 0x00; -const uint32_t FAN_MED = 0x20; -const uint32_t FAN_MAX = 0x40; +const uint32_t FAN_MIN = 0x00; // AKA F1 +const uint32_t FAN_F2 = 0x90; +const uint32_t FAN_MED = 0x20; // AKA F3 +const uint32_t FAN_F4 = 0xA0; +const uint32_t FAN_MAX = 0x40; // AKA F5 // Temperature const uint8_t TEMP_RANGE = TEMP_MAX - TEMP_MIN + 1; @@ -37,16 +113,37 @@ const uint32_t TEMP_SHIFT = 8; const uint16_t BITS = 28; void LgIrClimate::transmit_state() { - uint32_t remote_state = 0x8800000; + uint32_t remote_state = LG_HEADER; - // ESP_LOGD(TAG, "climate_lg_ir mode_before_ code: 0x%02X", modeBefore_); + // ESP_LOGD(TAG, "climate_lg_ir mode_before_ code: 0x%02X", this->modeBefore_); // Set command if (this->send_swing_cmd_) { this->send_swing_cmd_ = false; - remote_state |= COMMAND_SWING; - } else { - bool climate_is_off = (this->mode_before_ == climate::CLIMATE_MODE_OFF); + if (this->advanced_commands_support_) { + switch (this->swing_mode) { + case climate::CLIMATE_SWING_VERTICAL: + ESP_LOGD(TAG, "setting swing vertical"); + remote_state |= CommandAdvSwing::HEADER_ADV_SWING; + remote_state |= CommandAdvSwing::VERT_SWING_ON; + break; + case climate::CLIMATE_SWING_OFF: + ESP_LOGD(TAG, "setting swing off"); + remote_state |= CommandAdvSwing::HEADER_ADV_SWING; + remote_state |= CommandAdvSwing::VERT_SWING_OFF; + break; + default: + return; + } + this->transmit_(remote_state); + this->publish_state(); + return; + } else { // just toggle swing when advanced_commands_support is not set + remote_state |= HEADER_BASIC; + remote_state |= BASIC_SWING_TOGGLE; + } + } else { // Mode commands + const bool climate_is_off = (this->mode_before_ == climate::CLIMATE_MODE_OFF); switch (this->mode) { case climate::CLIMATE_MODE_COOL: remote_state |= climate_is_off ? COMMAND_ON_COOL : COMMAND_COOL; @@ -65,8 +162,8 @@ void LgIrClimate::transmit_state() { break; case climate::CLIMATE_MODE_OFF: default: - remote_state |= COMMAND_OFF; - break; + remote_state |= CommandSys::HEADER_SYS; + remote_state |= CommandSys::COMMAND_OFF; } } @@ -75,9 +172,8 @@ void LgIrClimate::transmit_state() { ESP_LOGD(TAG, "climate_lg_ir mode code: 0x%02X", this->mode); // Set fan speed - if (this->mode == climate::CLIMATE_MODE_OFF) { - remote_state |= FAN_AUTO; - } else { + if (this->mode != + climate::CLIMATE_MODE_OFF) { // https://github.com/esphome/esphome/pull/10875#issuecomment-5042765948 switch (this->fan_mode.value_or(climate::CLIMATE_FAN_ON)) { case climate::CLIMATE_FAN_HIGH: remote_state |= FAN_MAX; @@ -95,10 +191,20 @@ void LgIrClimate::transmit_state() { } } - // Set temperature - if (this->mode == climate::CLIMATE_MODE_COOL || this->mode == climate::CLIMATE_MODE_HEAT) { - auto temp = (uint8_t) roundf(clamp(this->target_temperature, TEMP_MIN, TEMP_MAX)); - remote_state |= ((temp - 15) << TEMP_SHIFT); + uint8_t temp; + switch (this->mode) { + case climate::CLIMATE_MODE_HEAT_COOL: + if (!this->advanced_commands_support_) { // Keep previous behavior + break; + } + [[fallthrough]]; + case climate::CLIMATE_MODE_COOL: + case climate::CLIMATE_MODE_HEAT: + temp = static_cast(roundf(clamp(this->target_temperature, TEMP_MIN, TEMP_MAX))); + remote_state |= (temp - 15) << TEMP_SHIFT; + break; + default: + break; } this->transmit_(remote_state); @@ -124,62 +230,134 @@ bool LgIrClimate::on_receive(remote_base::RemoteReceiveData data) { } } - ESP_LOGD(TAG, "Decoded 0x%02" PRIX32, remote_state); - if ((remote_state & 0xFF00000) != 0x8800000) + ESP_LOGD(TAG, "Received 0x%02" PRIX32, remote_state); + if ((remote_state & 0xFF00000) != LG_HEADER) return false; - // Get command - if ((remote_state & COMMAND_MASK) == COMMAND_OFF) { - this->mode = climate::CLIMATE_MODE_OFF; - } else if ((remote_state & COMMAND_MASK) == COMMAND_SWING) { - this->swing_mode = - this->swing_mode == climate::CLIMATE_SWING_OFF ? climate::CLIMATE_SWING_VERTICAL : climate::CLIMATE_SWING_OFF; - } else { - switch (remote_state & COMMAND_MASK) { - case COMMAND_DRY: - case COMMAND_ON_DRY: - this->mode = climate::CLIMATE_MODE_DRY; - break; - case COMMAND_FAN_ONLY: - case COMMAND_ON_FAN_ONLY: - this->mode = climate::CLIMATE_MODE_FAN_ONLY; - break; - case COMMAND_AI: - case COMMAND_ON_AI: - this->mode = climate::CLIMATE_MODE_HEAT_COOL; - break; - case COMMAND_HEAT: - case COMMAND_ON_HEAT: - this->mode = climate::CLIMATE_MODE_HEAT; - break; - case COMMAND_COOL: - case COMMAND_ON_COOL: - default: - this->mode = climate::CLIMATE_MODE_COOL; - break; - } - - // Get fan speed - if (this->mode == climate::CLIMATE_MODE_HEAT_COOL) { - this->fan_mode = climate::CLIMATE_FAN_AUTO; - } else if (this->mode == climate::CLIMATE_MODE_COOL || this->mode == climate::CLIMATE_MODE_DRY || - this->mode == climate::CLIMATE_MODE_FAN_ONLY || this->mode == climate::CLIMATE_MODE_HEAT) { - if ((remote_state & FAN_MASK) == FAN_AUTO) { - this->fan_mode = climate::CLIMATE_FAN_AUTO; - } else if ((remote_state & FAN_MASK) == FAN_MIN) { - this->fan_mode = climate::CLIMATE_FAN_LOW; - } else if ((remote_state & FAN_MASK) == FAN_MED) { - this->fan_mode = climate::CLIMATE_FAN_MEDIUM; - } else if ((remote_state & FAN_MASK) == FAN_MAX) { - this->fan_mode = climate::CLIMATE_FAN_HIGH; + // Decode commands + switch (remote_state & COMMAND_HEADER_MASK) { + case CommandSys::HEADER_SYS: + ESP_LOGD(TAG, "Got system command! With data: 0x%02" PRIX32, remote_state & COMMAND_DATA_MASK); + if ((remote_state & COMMAND_DATA_MASK) == CommandSys::COMMAND_OFF) { + this->mode = climate::CLIMATE_MODE_OFF; + } else { + return false; + } + break; + case CommandAdvSwing::HEADER_ADV_SWING: + ESP_LOGD(TAG, "Got advanced swing command! With data: 0x%02" PRIX32, + remote_state & CommandAdvSwing::ADV_SWING_DATA_MASK); + switch (remote_state & CommandAdvSwing::ADV_SWING_DATA_MASK) { + case CommandAdvSwing::VERT_SWING_ON: + this->swing_mode = climate::CLIMATE_SWING_VERTICAL; + break; + case CommandAdvSwing::VERT_SWING_OFF: + case CommandAdvSwing::VERT_FIX_1: + case CommandAdvSwing::VERT_FIX_2: + case CommandAdvSwing::VERT_FIX_3: + case CommandAdvSwing::VERT_FIX_4: + case CommandAdvSwing::VERT_FIX_5: + case CommandAdvSwing::VERT_FIX_6: + this->swing_mode = climate::CLIMATE_SWING_OFF; + break; + default: + return false; // Ignore all other (horizontal) swing commands } - } - // Get temperature - if (this->mode == climate::CLIMATE_MODE_COOL || this->mode == climate::CLIMATE_MODE_HEAT) { - this->target_temperature = ((remote_state & TEMP_MASK) >> TEMP_SHIFT) + 15; - } + this->publish_state(); + return true; + + case HEADER_BASIC: + if ((remote_state & COMMAND_DATA_MASK) == BASIC_JET) { + switch (this->mode) { + case climate::CLIMATE_MODE_COOL: + case climate::CLIMATE_MODE_HEAT: + case climate::CLIMATE_MODE_DRY: + this->target_temperature = + this->mode == climate::CLIMATE_MODE_HEAT ? this->maximum_temperature_ : this->minimum_temperature_; + this->fan_mode = climate::CLIMATE_FAN_HIGH; + // When enabling PO(WER) also known as JET mode, swing is set to VERT_3, but after 30 mins it will switch + // back to what it was before, so let's just not change it here it at all + this->publish_state(); + return true; + default: + ESP_LOGD(TAG, "Got jet command, but current mode does not support it! Ignoring."); + return false; + } + } + + // Keep previous behavior in case of other BASIC command + if (this->swing_mode == climate::CLIMATE_SWING_OFF) { // Just flip between vertical and off + this->swing_mode = climate::CLIMATE_SWING_VERTICAL; + } else { + this->swing_mode = climate::CLIMATE_SWING_OFF; + } + this->publish_state(); + return true; + // Following commands also contain fan speed and temperature, so no 'return' in these cases + case COMMAND_DRY: + case COMMAND_ON_DRY: + this->mode = climate::CLIMATE_MODE_DRY; + break; + case COMMAND_FAN_ONLY: + case COMMAND_ON_FAN_ONLY: + this->mode = climate::CLIMATE_MODE_FAN_ONLY; + break; + case COMMAND_AI: + case COMMAND_ON_AI: + this->mode = climate::CLIMATE_MODE_HEAT_COOL; + break; + case COMMAND_HEAT: + case COMMAND_ON_HEAT: + this->mode = climate::CLIMATE_MODE_HEAT; + break; + case COMMAND_COOL: + case COMMAND_ON_COOL: + this->mode = climate::CLIMATE_MODE_COOL; + break; + default: + ESP_LOGD(TAG, "Got unknown command! Ignoring!"); + return false; } + + // Decode fan speed + switch (remote_state & FAN_SPEED_MASK) { + case FAN_AUTO: + this->fan_mode = climate::CLIMATE_FAN_AUTO; + break; + case FAN_MIN: + case FAN_F2: + this->fan_mode = climate::CLIMATE_FAN_LOW; + break; + case FAN_MED: + case FAN_F4: + this->fan_mode = climate::CLIMATE_FAN_MEDIUM; + break; + case FAN_MAX: + this->fan_mode = climate::CLIMATE_FAN_HIGH; + break; + default: + ESP_LOGD(TAG, "Got unknown fan speed! Ignoring!"); + return false; + } + + // Keep previous behavior + if (this->mode == climate::CLIMATE_MODE_HEAT_COOL && !(this->advanced_commands_support_)) { + this->fan_mode = climate::CLIMATE_FAN_AUTO; + } + + // Decode temperature for modes that support it + switch (this->mode) { + case climate::CLIMATE_MODE_HEAT_COOL: + case climate::CLIMATE_MODE_COOL: + case climate::CLIMATE_MODE_HEAT: + this->target_temperature = ((remote_state & TEMP_MASK) >> TEMP_SHIFT) + 15; + break; + default: + break; + } + + this->mode_before_ = this->mode; this->publish_state(); return true; @@ -207,14 +385,14 @@ void LgIrClimate::transmit_(uint32_t value) { data->mark(this->bit_high_); transmit.perform(); } + void LgIrClimate::calc_checksum_(uint32_t &value) { - uint32_t mask = 0xF; uint32_t sum = 0; for (uint8_t i = 1; i < 8; i++) { - sum += (value & (mask << (i * 4))) >> (i * 4); + sum += (value & (CHECKSUM_MASK << (i * 4))) >> (i * 4); } - value |= (sum & mask); + value |= (sum & CHECKSUM_MASK); } } // 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 341f0a4ef1..c9c0c0c005 100644 --- a/esphome/components/climate_ir_lg/climate_ir_lg.h +++ b/esphome/components/climate_ir_lg/climate_ir_lg.h @@ -21,12 +21,13 @@ class LgIrClimate final : public climate_ir::ClimateIR { /// Override control to change settings of the climate device. void control(const climate::ClimateCall &call) override { this->send_swing_cmd_ = call.get_swing_mode().has_value(); - // swing resets after unit powered off + // swing resets after unit powered off, except when advanced_commands_support_ is set auto mode = call.get_mode(); - if (mode.has_value() && *mode == climate::CLIMATE_MODE_OFF) + if (mode.has_value() && *mode == climate::CLIMATE_MODE_OFF && !(this->advanced_commands_support_)) this->swing_mode = climate::CLIMATE_SWING_OFF; climate_ir::ClimateIR::control(call); } + void set_advanced_commands_support(bool value) { this->advanced_commands_support_ = value; } void set_header_high(uint32_t header_high) { this->header_high_ = header_high; } void set_header_low(uint32_t header_low) { this->header_low_ = header_low; } void set_bit_high(uint32_t bit_high) { this->bit_high_ = bit_high; } @@ -44,6 +45,7 @@ class LgIrClimate final : public climate_ir::ClimateIR { void calc_checksum_(uint32_t &value); void transmit_(uint32_t value); + bool advanced_commands_support_{false}; uint32_t header_high_; uint32_t header_low_; uint32_t bit_high_; diff --git a/tests/components/climate_ir_lg/common.yaml b/tests/components/climate_ir_lg/common.yaml index e0bc185d2c..5536c36742 100644 --- a/tests/components/climate_ir_lg/common.yaml +++ b/tests/components/climate_ir_lg/common.yaml @@ -12,5 +12,8 @@ climate: - platform: climate_ir_lg name: LG Climate transmitter_id: xmitr + header_high: 3300us + header_low: 9840us + advanced_commands_support: true sensor: climate_ir_lg_temp_sensor humidity_sensor: humidity_sensor From 1e7c48e2cfc6eac01ec515b34be57cde768d67d4 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Wed, 26 Aug 2026 06:35:46 -0700 Subject: [PATCH 15/30] [modbus_controller] Add integration tests for register offset, response size and write buffer (#18741) --- ...t_mock_modbus_deprecated_write_buffer.yaml | 106 ++++++++++++++ .../uart_mock_modbus_register_offset.yaml | 138 ++++++++++++++++++ tests/integration/test_uart_mock_modbus.py | 119 ++++++++++++++- 3 files changed, 362 insertions(+), 1 deletion(-) create mode 100644 tests/integration/fixtures/uart_mock_modbus_deprecated_write_buffer.yaml create mode 100644 tests/integration/fixtures/uart_mock_modbus_register_offset.yaml diff --git a/tests/integration/fixtures/uart_mock_modbus_deprecated_write_buffer.yaml b/tests/integration/fixtures/uart_mock_modbus_deprecated_write_buffer.yaml new file mode 100644 index 0000000000..f378e3de43 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_deprecated_write_buffer.yaml @@ -0,0 +1,106 @@ +esphome: + name: uart-mock-modbus-dep-buffer + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_server + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_controller + data: !lambda return data; + - id: virtual_uart_controller + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + +globals: + - id: reg10 + type: uint16_t + initial_value: "0" + +modbus: + - uart_id: virtual_uart_server + id: virtual_modbus_server + role: server + - uart_id: virtual_uart_controller + id: virtual_modbus_controller + role: client + turnaround_time: 10ms + +modbus_controller: + - address: 1 + modbus_id: virtual_modbus_controller + id: modbus_controller_1 + update_interval: 1s + +modbus_server: + - address: 1 + modbus_id: virtual_modbus_server + id: modbus_server_1 + registers: + - address: 0x10 + value_type: U_WORD + read_lambda: return id(reg10); + write_lambda: |- + id(reg10) = x; + return true; + +# A number whose write_lambda uses the DEPRECATED buffer parameter (fills `payload` with a legacy raw +# frame as words: device address + function code + data) instead of the new item->write_* API. The write +# must still land with its legacy semantics, and the one-time deprecation warning must fire only once per +# entity no matter how many writes happen. +number: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "buf_number" + id: buf_number + address: 0x10 + register_type: holding + value_type: U_WORD + min_value: 0 + max_value: 1000 + step: 1 + write_lambda: |- + // Legacy raw frame as words: [addr 0x01 | fc 0x06], register 0x0010, value. + payload.push_back(0x0106); + payload.push_back(0x0010); + payload.push_back((uint16_t) x); + return {}; + +# Reports the server-side register so the test can observe that the deprecated buffer write landed. +sensor: + - platform: template + name: "written_value" + id: written_value + update_interval: 0.5s + lambda: "return id(reg10);" + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + # The test drives the writes via number_command; the mock is autostart. diff --git a/tests/integration/fixtures/uart_mock_modbus_register_offset.yaml b/tests/integration/fixtures/uart_mock_modbus_register_offset.yaml new file mode 100644 index 0000000000..e93e78d5a3 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_register_offset.yaml @@ -0,0 +1,138 @@ +esphome: + name: uart-mock-modbus-reg-offset + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_server + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_controller + data: !lambda return data; + - id: virtual_uart_controller + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + +globals: + - id: reg10 + type: uint16_t + initial_value: "100" + - id: reg11 + type: uint16_t + initial_value: "200" + - id: reg12 + type: uint16_t + initial_value: "300" + - id: reg13 + type: uint16_t + initial_value: "0xABCD" + +modbus: + - uart_id: virtual_uart_server + id: virtual_modbus_server + role: server + - uart_id: virtual_uart_controller + id: virtual_modbus_controller + role: client + turnaround_time: 10ms + +modbus_controller: + - address: 1 + modbus_id: virtual_modbus_controller + id: modbus_controller_1 + update_interval: 1s + +modbus_server: + - address: 1 + modbus_id: virtual_modbus_server + id: modbus_server_1 + registers: + - address: 0x10 + value_type: U_WORD + read_lambda: return id(reg10); + write_lambda: id(reg10) = x; return true; + - address: 0x11 + value_type: U_WORD + read_lambda: return id(reg11); + write_lambda: id(reg11) = x; return true; + - address: 0x12 + value_type: U_WORD + read_lambda: return id(reg12); + write_lambda: id(reg12) = x; return true; + - address: 0x13 + value_type: U_WORD + read_lambda: return id(reg13); + write_lambda: id(reg13) = x; return true; + +# A holding-register switch at 0x10 with a 2-BYTE offset. offset is byte-based, so the write must target +# register 0x10 + 2/2 = 0x11. The old (pre-fix) behavior folded offset into the address as a register +# count, hitting 0x12 instead. assumed_state keeps the switch write-only so it does not read any register. +switch: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "offset_switch" + register_type: holding + address: 0x10 + offset: 2 + assumed_state: true + # A holding-register switch that READS its state. Byte offset 6 -> register 0x10 + 6/2 = 0x13. Post-fix + # the switch itself resolves to 0x13 (whole registers fold into the address, residual byte stays) and + # joins the 0x10..0x13 range, so no separate 0x13 sensor is needed. Pre-fix the whole byte offset folds + # into the address (0x16), where the server answers ILLEGAL_DATA_ADDRESS and the switch never publishes. + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "read_offset_switch" + register_type: holding + address: 0x10 + offset: 6 + bitmask: 0x1 + +sensor: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_10" + address: 0x10 + register_type: holding + value_type: U_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_11" + address: 0x11 + register_type: holding + value_type: U_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_12" + address: 0x12 + register_type: holding + value_type: U_WORD + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index c84fb34e70..707637cfc2 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -24,7 +24,7 @@ from dataclasses import dataclass from aioesphomeapi import ButtonInfo, NumberInfo, SwitchInfo import pytest -from .state_utils import SensorTracker, find_entity +from .state_utils import SensorTracker, find_entity, wait_for_state from .types import APIClientConnectedFactory, RunCompiledFunction @@ -965,3 +965,120 @@ async def test_uart_mock_modbus_client_read_write( await tracker.setup_and_start_scenario(client) await tracker.await_all(futures) _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + +@pytest.mark.xfail( + strict=True, + reason="Byte-accurate register-offset writes require the modbus_controller " + "entity-device change; on dev the byte offset is folded into the address " + "(writes 0x12 instead of 0x11). The write and read assertions both flip via " + "the same switch-constructor fold. Remove this marker when that change merges.", +) +@pytest.mark.asyncio +async def test_uart_mock_modbus_register_offset( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that a byte offset on a holding-register write is byte-accurate. + + `offset` is a byte offset, so a holding-register write at address 0x10 with offset: 2 must target + register 0x10 + 2/2 = 0x11. The pre-fix behavior folded the byte offset into the address as a register + count (0x10 + 2 = 0x12). The switch is assumed_state (write-only), so reg_11 turning 0xFFFF pins the + fix; had the write landed on 0x12 the wait would time out and reg_12 would change instead. + """ + + tracker = SensorTracker(["reg_10", "reg_11", "reg_12"]) + initial = tracker.expect_all({"reg_10": 100, "reg_11": 200, "reg_12": 300}) + wrote_11 = tracker.expect("reg_11", 65535) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + entities = await tracker.setup_and_start_scenario(client) + await tracker.await_all(initial, timeout=4.0) + + switch = find_entity(entities, "offset_switch", SwitchInfo) + assert switch is not None, "offset_switch not found" + client.switch_command(switch.key, True) + + # reg_11 (0x10 + offset 2/2) must receive the write; if the write went to 0x12 this times out. + await tracker.await_change(wrote_11, "reg_11", timeout=4.0) + # And 0x12 (the pre-fix register-offset target) must be untouched. + assert tracker.sensor_states["reg_12"][-1] == 300, ( + "reg_12 (0x12) should be untouched - offset is byte-based, so the write targets 0x11; " + f"got {tracker.sensor_states['reg_12']}" + ) + + # Read path: read_offset_switch has byte offset 6. Post-fix the switch folds the whole registers + # into its address (0x10 + 6/2 = 0x13, residual byte 0) and joins the 0x10..0x13 range, so the + # read lands in-bounds on 0xABCD (bit 0 set) -> ON. Pre-fix the whole byte offset folded into the + # address (0x16); the server answers ILLEGAL_DATA_ADDRESS there and the switch never publishes. + read_switch = find_entity(entities, "read_offset_switch", SwitchInfo) + assert read_switch is not None, "read_offset_switch not found" + # The ON transition happened at the first poll and switch states are deduped, so this relies on + # wait_for_state's fresh subscribe_states re-dumping every entity's current state. + await wait_for_state( + client, + lambda s: ( + getattr(s, "key", None) == read_switch.key + and getattr(s, "state", None) is True + ), + timeout=6.0, + ) + + +@pytest.mark.xfail( + strict=True, + reason="The deprecated write buffer requires the modbus_controller " + "entity-device change; on dev a nullopt-returning write_lambda early-returns " + "before the buffer is used, so the write never happens. The warn-once " + "assertion matches the log substring 'write_lambda buffer'. Remove this " + "marker when that change merges.", +) +@pytest.mark.asyncio +async def test_uart_mock_modbus_deprecated_write_buffer( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test the deprecated write_lambda buffer path still works, and warns once per entity. + + buf_number's write_lambda fills the old `payload` buffer with a legacy raw frame as words (device + address + function code + data) instead of calling item->write_*. Two writes must both land with the + legacy raw-frame semantics, and the one-time deprecation warning must fire exactly once per entity + regardless of how many writes happen. + """ + + warn_count = 0 + + def line_callback(line: str) -> None: + nonlocal warn_count + if "write_lambda buffer" in line: + warn_count += 1 + + tracker = SensorTracker(["written_value"]) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + entities = await tracker.setup_and_start_scenario(client) + number = find_entity(entities, "buf_number", NumberInfo) + assert number is not None, "buf_number not found" + + # First write via the deprecated buffer path. + client.number_command(number.key, 111) + await tracker.await_change( + tracker.expect("written_value", 111), "written_value", timeout=4.0 + ) + # Second write: lands too, but must not warn again (warn-once per entity). + client.number_command(number.key, 222) + await tracker.await_change( + tracker.expect("written_value", 222), "written_value", timeout=4.0 + ) + + assert warn_count == 1, ( + f"deprecation warning should fire exactly once per entity, got {warn_count}" + ) From 4eb85a24c20f5eab14ccefb487e196832fafe890 Mon Sep 17 00:00:00 2001 From: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:04:57 +1000 Subject: [PATCH 16/30] [mipi_spi] Fix dimensions for jc3636518v2 (#18786) --- esphome/components/mipi_spi/models/jc.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/esphome/components/mipi_spi/models/jc.py b/esphome/components/mipi_spi/models/jc.py index ca9adb4a72..8d2591aefe 100644 --- a/esphome/components/mipi_spi/models/jc.py +++ b/esphome/components/mipi_spi/models/jc.py @@ -266,8 +266,6 @@ DriverChip( "JC3636W518V2", height=360, width=360, - offset_height=1, - draw_rounding=1, cs_pin=10, reset_pin=47, invert_colors=True, From 7b4894da03677670e5f2dc712599e1a280801e9c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 26 Aug 2026 10:16:44 -0500 Subject: [PATCH 17/30] [mdns] Skip MDNS.update() while the ESP8266 radio cannot transmit (#18785) --- esphome/components/mdns/mdns_esp8266.cpp | 14 +++++++++++++- esphome/components/wifi/wifi_component.cpp | 6 +++--- esphome/components/wifi/wifi_component.h | 7 +++++++ esphome/components/wifi/wifi_component_esp8266.cpp | 2 +- esphome/components/wifi/wifi_component_esp_idf.cpp | 2 +- 5 files changed, 25 insertions(+), 6 deletions(-) diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index f6d5786675..1f0b3c9519 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -41,7 +41,19 @@ static void register_esp8266(MDNSComponent *, StaticVectorset_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); }); + this->set_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, []() { +#ifdef USE_MDNS_WIFI_LISTENER + // MDNS.update() can suspend the loop in UdpContext::sendTimeout() while a send is + // failing (radio off-channel during a roam scan, or mid reconnect); an incoming + // packet then re-enters LEAmDNS from lwIP and corrupts shared UdpContext state. + // Skip the tick while the radio cannot transmit (#18760), but keep polling while + // the AP is serving clients (AP-only or fallback AP with the STA down). + auto *wifi = wifi::global_wifi_component; + if (wifi->is_roaming() || (!wifi->is_connected() && !wifi->is_ap_active())) + return; +#endif + MDNS.update(); + }); this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); }); } #endif diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index b8a31f97a3..d82929e5cb 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -530,7 +530,7 @@ void WiFiComponent::log_discarded_scan_result_(const char *ssid, const uint8_t * #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE // Skip logging during roaming scans to avoid log buffer overflow // (roaming scans typically find many networks but only care about same-SSID APs) - if (this->roaming_state_ == RoamingState::SCANNING) { + if (this->is_roaming_scan_active()) { return; } char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; @@ -833,7 +833,7 @@ void WiFiComponent::loop() { // Post-connect roaming: check for better AP if (this->post_connect_roaming_) { - if (this->roaming_state_ == RoamingState::SCANNING) { + if (this->is_roaming_scan_active()) { if (this->scan_done_) { this->process_roaming_scan_(); } @@ -2144,7 +2144,7 @@ void WiFiComponent::retry_connect() { // Roam connection failed - transition to reconnecting ESP_LOGD(TAG, "Roam failed, reconnecting (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS); this->roaming_state_ = RoamingState::RECONNECTING; - } else if (this->roaming_state_ == RoamingState::SCANNING) { + } else if (this->is_roaming_scan_active()) { // Disconnected during roam scan - transition to RECONNECTING so the attempts // counter is preserved when reconnection succeeds (IDLE would reset it) ESP_LOGD(TAG, "Disconnected during roam scan (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS); diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 382d3d5932..cfdbc1a968 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -478,6 +478,13 @@ class WiFiComponent final : public Component { bool is_connected() const { return this->connected_; } + /// True while a post-connect roaming scan holds the radio off-channel. + bool is_roaming_scan_active() const { return this->roaming_state_ == RoamingState::SCANNING; } + + /// True while a post-connect roam is in progress (scanning off-channel, reassociating, + /// or recovering from a failed roam). + bool is_roaming() const { return this->roaming_state_ != RoamingState::IDLE; } + #ifdef USE_ESP32 /// esp_netif handle of the station interface, used by network for default-route /// arbitration. nullptr until wifi_lazy_init_() has run. diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 005d655d88..b4a91fb3cd 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -717,7 +717,7 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { static constexpr uint32_t SCAN_ACTIVE_MAX_DEFAULT_MS = 500; static constexpr uint32_t SCAN_ACTIVE_MIN_ROAMING_MS = 100; static constexpr uint32_t SCAN_ACTIVE_MAX_ROAMING_MS = 300; - bool roaming = this->roaming_state_ == RoamingState::SCANNING; + bool roaming = this->is_roaming_scan_active(); if (passive) { config.scan_time.passive = roaming ? SCAN_PASSIVE_ROAMING_MS : SCAN_PASSIVE_DEFAULT_MS; } else { diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 06f0981020..ce75d21330 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -1059,7 +1059,7 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { // When scanning while connected (roaming), return to home channel between // each scanned channel to maintain the connection (helps with BLE/WiFi coexistence) #ifdef CONFIG_SOC_WIFI_SUPPORTED - if (this->roaming_state_ == RoamingState::SCANNING) { + if (this->is_roaming_scan_active()) { config.coex_background_scan = true; } #endif From 5c79c92c0657ef8ae9cee1745bddbe3e7e0fc439 Mon Sep 17 00:00:00 2001 From: guillempages Date: Wed, 26 Aug 2026 18:26:39 +0200 Subject: [PATCH 18/30] [runtime_image] Add FILTER_SOURCE_FILES (#18768) --- esphome/components/runtime_image/__init__.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/esphome/components/runtime_image/__init__.py b/esphome/components/runtime_image/__init__.py index 9277c214ff..a220503045 100644 --- a/esphome/components/runtime_image/__init__.py +++ b/esphome/components/runtime_image/__init__.py @@ -10,6 +10,7 @@ from esphome.components.image import ( validate_transparency, validate_type, ) +from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import CONF_FORMAT, CONF_ID, CONF_RESIZE, CONF_TYPE from esphome.core import CORE @@ -124,6 +125,15 @@ IMAGE_FORMATS = { "PNG": PNGFormat(), } +FILTER_SOURCE_FILES = filter_source_files_from_defines( + { + "bmp_decoder.cpp": "USE_RUNTIME_IMAGE_BMP", + "jpeg_decoder.cpp": "USE_RUNTIME_IMAGE_JPEG", + "png_decoder.cpp": "USE_RUNTIME_IMAGE_PNG", + "qoi_decoder.cpp": "USE_RUNTIME_IMAGE_QOI", + } +) + AUTO_FORMAT = AUTOFormat() From 1cbe3a49b2bf9375f0c778f9d44084c12ec3eaea Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Wed, 26 Aug 2026 11:30:07 -0500 Subject: [PATCH 19/30] [remote_transmitter] ISR-driven transmission and non_blocking support on BK7231N/BK7238 (#18660) --- .../components/remote_transmitter/__init__.py | 26 +- .../remote_transmitter/remote_transmitter.cpp | 4 +- .../remote_transmitter/remote_transmitter.h | 44 +++- .../remote_transmitter_bk72xx.cpp | 187 +++++++++++++++ .../remote_transmitter_libretiny_isr.cpp | 224 ++++++++++++++++++ .../remote_transmitter_rtl87xx.cpp | 212 ++--------------- .../test_non_blocking_gate.py | 6 + .../remote_transmitter/test.bk72xx-ard.yaml | 1 + 8 files changed, 497 insertions(+), 207 deletions(-) create mode 100644 esphome/components/remote_transmitter/remote_transmitter_bk72xx.cpp create mode 100644 esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index 8ae51829e7..cb2aebec91 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -4,7 +4,11 @@ from esphome import automation, pins import esphome.codegen as cg from esphome.components import esp32, esp32_rmt, remote_base from esphome.components.libretiny import get_libretiny_family -from esphome.components.libretiny.const import FAMILY_RTL8720C +from esphome.components.libretiny.const import ( + FAMILY_BK7231N, + FAMILY_BK7238, + FAMILY_RTL8720C, +) from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( @@ -45,14 +49,19 @@ DigitalWriteAction = remote_transmitter_ns.class_( ) +_NON_BLOCKING_LIBRETINY_FAMILIES = (FAMILY_RTL8720C, FAMILY_BK7231N, FAMILY_BK7238) + + def _validate_non_blocking_platform(value: bool) -> bool: - # non_blocking requires hardware transmission: RMT on ESP32, the gtimer - # envelope chain on RTL8720C. Reject everywhere else at config time. + # non_blocking requires hardware transmission: RMT on ESP32, a hardware timer + # envelope chain on the listed LibreTiny families. Reject elsewhere at config time. if CORE.is_esp32: return cv.boolean(value) - if CORE.is_libretiny and get_libretiny_family() == FAMILY_RTL8720C: + if CORE.is_libretiny and get_libretiny_family() in _NON_BLOCKING_LIBRETINY_FAMILIES: return cv.boolean(value) - raise cv.Invalid("non_blocking is only supported on ESP32 and RTL8720C") + raise cv.Invalid( + "non_blocking is only supported on ESP32, RTL8720C, BK7231N and BK7238" + ) MULTI_CONF = True @@ -202,6 +211,13 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( "remote_transmitter_rtl87xx.cpp": { PlatformFramework.RTL87XX_ARDUINO, }, + "remote_transmitter_bk72xx.cpp": { + PlatformFramework.BK72XX_ARDUINO, + }, + "remote_transmitter_libretiny_isr.cpp": { + PlatformFramework.RTL87XX_ARDUINO, + PlatformFramework.BK72XX_ARDUINO, + }, "remote_transmitter.cpp": { PlatformFramework.ESP32_ARDUINO, PlatformFramework.ESP32_IDF, diff --git a/esphome/components/remote_transmitter/remote_transmitter.cpp b/esphome/components/remote_transmitter/remote_transmitter.cpp index 67341e936f..5e82213a48 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter.cpp @@ -2,8 +2,8 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" -#if (defined(USE_LIBRETINY) && !defined(USE_RTL87XX)) || defined(USE_ESP8266) || defined(USE_RP2) || \ - (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) +#if (defined(USE_LIBRETINY) && !defined(USE_RTL87XX) && !defined(REMOTE_TRANSMITTER_BK_PWM)) || \ + defined(USE_ESP8266) || defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) namespace esphome::remote_transmitter { diff --git a/esphome/components/remote_transmitter/remote_transmitter.h b/esphome/components/remote_transmitter/remote_transmitter.h index ef9a80f668..313b26364d 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.h +++ b/esphome/components/remote_transmitter/remote_transmitter.h @@ -12,6 +12,13 @@ #endif // SOC_RMT_SUPPORTED #endif // USE_ESP32 +// The BK7231N-style PWM block (hardware shadow-load duty updates) enables the ISR-driven +// transmitter on these families; family-level proxy for the SDK's CFG_SOC_NAME gate. +// See remote_transmitter_bk72xx.cpp. +#if defined(USE_LIBRETINY_VARIANT_BK7231N) || defined(USE_LIBRETINY_VARIANT_BK7238) +#define REMOTE_TRANSMITTER_BK_PWM +#endif + namespace esphome::remote_transmitter { #if defined(USE_ESP32) && SOC_RMT_SUPPORTED @@ -57,13 +64,16 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa void set_with_dma(bool with_dma) { this->with_dma_ = with_dma; } void set_eot_level(bool eot_level) { this->eot_level_ = eot_level; } #endif -#if (defined(USE_ESP32) && SOC_RMT_SUPPORTED) || defined(USE_LIBRETINY_VARIANT_RTL8720C) +#if (defined(USE_ESP32) && SOC_RMT_SUPPORTED) || defined(USE_LIBRETINY_VARIANT_RTL8720C) || \ + defined(REMOTE_TRANSMITTER_BK_PWM) void set_non_blocking(bool non_blocking) { this->non_blocking_ = non_blocking; } #endif -#ifdef USE_LIBRETINY_VARIANT_RTL8720C +#if defined(USE_LIBRETINY_VARIANT_RTL8720C) || defined(REMOTE_TRANSMITTER_BK_PWM) void loop() override; // called from the envelope timer ISR trampoline; not part of the public API void advance_envelope_isr(); + // same, for trampolines whose SDK callback carries no user argument + static void advance_active_isr(); #endif Trigger<> *get_transmit_trigger() { return &this->transmit_trigger_; } @@ -71,12 +81,14 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa protected: void send_internal(uint32_t send_times, uint32_t send_wait) override; -#if defined(USE_ESP8266) || (defined(USE_LIBRETINY) && !defined(USE_LIBRETINY_VARIANT_RTL8720C)) || \ +#if defined(USE_ESP8266) || \ + (defined(USE_LIBRETINY) && !defined(USE_LIBRETINY_VARIANT_RTL8720C) && !defined(REMOTE_TRANSMITTER_BK_PWM)) || \ defined(USE_RP2) || (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) void await_target_time_(); uint32_t target_time_{0}; #endif -#if defined(USE_ESP8266) || (defined(USE_LIBRETINY) && !defined(USE_RTL87XX)) || defined(USE_RP2) || \ +#if defined(USE_ESP8266) || \ + (defined(USE_LIBRETINY) && !defined(USE_RTL87XX) && !defined(REMOTE_TRANSMITTER_BK_PWM)) || defined(USE_RP2) || \ (defined(USE_ESP32) && !SOC_RMT_SUPPORTED) void calculate_on_off_time_(uint32_t carrier_frequency, uint32_t *on_time_period, uint32_t *off_time_period); @@ -89,17 +101,22 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa uint32_t current_carrier_frequency_{0}; void *pwm_{nullptr}; // pwmout_t*, opaque here to keep the SDK header out of this shared header #endif -#ifdef USE_LIBRETINY_VARIANT_RTL8720C +#if defined(USE_LIBRETINY_VARIANT_RTL8720C) || defined(REMOTE_TRANSMITTER_BK_PWM) + // Envelope chain, shared by every family that paces transmission from a hardware timer + // (remote_transmitter_libretiny_isr.cpp) void start_isr_item_(size_t index); void arm_envelope_timer_(uint32_t duration_us); void abort_stalled_chain_(); void deliver_completion_(); void wait_until_idle_(); void arm_chain_(uint32_t send_times, uint32_t send_wait); - void update_carrier_(uint32_t carrier_frequency); + // Hooks implemented per family: everything the chain needs from the hardware + bool envelope_ready_() const; // PWM claimed successfully in setup() + void prepare_carrier_(uint32_t carrier_frequency); // retune period, stage mark/space levels + void write_envelope_level_(bool mark); // drive carrier (mark) or idle (space) + void arm_one_shot_(uint32_t duration_us); // fire advance_envelope_isr after duration_us + void stop_envelope_timer_(); std::vector isr_data_; // owned copy of the frame; temp_ may be re-encoded mid-flight - float isr_mark_duty_{0.0f}; - float isr_space_duty_{0.0f}; volatile size_t isr_index_{0}; volatile uint32_t isr_repeats_left_{0}; uint32_t isr_send_wait_{0}; @@ -110,6 +127,17 @@ class RemoteTransmitterComponent final : public remote_base::RemoteTransmitterBa bool complete_pending_{false}; bool stall_aborted_{false}; // this transmission ended via abort; blocks warning clear #endif +#ifdef USE_LIBRETINY_VARIANT_RTL8720C + float isr_mark_duty_{0.0f}; + float isr_space_duty_{0.0f}; +#endif +#ifdef REMOTE_TRANSMITTER_BK_PWM + void write_pwm_t1_(uint32_t t1_counts); + uint32_t isr_mark_t1_{0}; + uint32_t isr_space_t1_{0}; + uint32_t isr_period_t4_{684}; // 26MHz counts; ~38kHz default until a send sets the real carrier + int8_t pwm_channel_{-1}; +#endif #if defined(USE_ESP32) && SOC_RMT_SUPPORTED void configure_rmt_(); diff --git a/esphome/components/remote_transmitter/remote_transmitter_bk72xx.cpp b/esphome/components/remote_transmitter/remote_transmitter_bk72xx.cpp new file mode 100644 index 0000000000..0081ae47b3 --- /dev/null +++ b/esphome/components/remote_transmitter/remote_transmitter_bk72xx.cpp @@ -0,0 +1,187 @@ +#include "remote_transmitter.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +// clang-tidy cannot parse the Beken SDK headers pulled in via ArduinoPrivate.h +#if defined(USE_BK72XX) && !defined(CLANG_TIDY) + +// ArduinoPrivate.h = Arduino.h + the BDK SDK headers (pwm_pub.h, bk_timer_pub.h, icu_pub.h) +// with the core's fixes for type-name collisions between the two +#include + +// Only the BK7231N-style PWM block (shadow registers with a hardware CFG_UPDATA load bit) +// supports glitch-free per-edge duty updates; older SoCs compile the generic bit-bang +// implementation (remote_transmitter.cpp) instead, and this file compiles to nothing. +// REMOTE_TRANSMITTER_BK_PWM is set per-family in remote_transmitter.h. + +namespace esphome::remote_transmitter { + +static const char *const TAG = "remote_transmitter"; + +#ifdef REMOTE_TRANSMITTER_BK_PWM + +// PWM peripheral carrier (26MHz block), envelope paced by a BKTIMER1 interrupt chain: each +// interrupt writes the next duty through the shadow registers (T1..T4 + CFG_UPDATA hardware +// load, glitch-free at the next carrier period). Direct register writes beat the driver's +// pwm_update_param() (~19us vs ~26us edge error) and have no shared state to race against. +// BKTIMER1 is the only free channel: TIMER0 = FreeRTOS tick, TIMER2 = SDK cal, TIMER4 = wdt. + +static constexpr uint32_t REG_PWM_BASE = 0x00802B00UL; +static constexpr uint32_t REG_PWM_GROUP_STRIDE = 0x40; // one register group per channel pair +static constexpr uint32_t REG_PWM_T_REGS[2] = {0x04, 0x14}; // T1..T4 offsets within a group +static constexpr uint32_t PWM_INT_STATUS_MASK = 3UL << 30; // write-1-clear -- always write as zero +static constexpr uint8_t ENVELOPE_TIMER = BKTIMER1; + +// The bk_timer handler receives only the channel number, so the chain resolves the instance +// that owns the timer. No IRAM_ATTR: hal.h makes it a no-op on BK72xx (the SDK masks IRQs +// around flash writes). +static void envelope_timer_isr(UINT8 channel) { RemoteTransmitterComponent::advance_active_isr(); } + +// Channel <-> pin comes from the board variant's own PIN_PWMn defines rather than a +// family-wide assumption, so an unusual pinout maps correctly instead of silently +// driving another pad +struct PwmPinChannel { + uint8_t pin; + int8_t channel; +}; +static constexpr PwmPinChannel PWM_PIN_CHANNELS[] = { +#ifdef PIN_PWM0 + {PIN_PWM0, 0}, +#endif +#ifdef PIN_PWM1 + {PIN_PWM1, 1}, +#endif +#ifdef PIN_PWM2 + {PIN_PWM2, 2}, +#endif +#ifdef PIN_PWM3 + {PIN_PWM3, 3}, +#endif +#ifdef PIN_PWM4 + {PIN_PWM4, 4}, +#endif +#ifdef PIN_PWM5 + {PIN_PWM5, 5}, +#endif +}; + +static int8_t pwm_channel_for_pin(uint8_t pin) { + for (const auto &entry : PWM_PIN_CHANNELS) { + if (entry.pin == pin) + return entry.channel; + } + return -1; +} + +void RemoteTransmitterComponent::setup() { + // Deliberately no pin_->setup(): the pin must belong to the PWM function, not GPIO + const int8_t channel = pwm_channel_for_pin(this->pin_->get_pin()); + if (channel < 0) { + ESP_LOGE(TAG, "Pin %u is not PWM-capable", this->pin_->get_pin()); + this->mark_failed(); + return; + } + this->pwm_channel_ = channel; + const uint32_t idle_t1 = this->pin_->is_inverted() ? this->isr_period_t4_ : 0; + pwm_param_st param{}; + param.chan = channel; + param.t1 = idle_t1; + param.t4 = this->isr_period_t4_; + param.init_level = idle_t1 ? 1 : 0; + if (pwm_init_param(¶m) != 0 || pwm_start(channel) != 0) { + ESP_LOGE(TAG, "PWM init failed on pin %u", this->pin_->get_pin()); + this->pwm_channel_ = -1; + this->mark_failed(); + return; + } + this->disable_loop(); // loop() is only needed while a non-blocking completion is pending +} + +void RemoteTransmitterComponent::dump_config() { + ESP_LOGCONFIG(TAG, + "Remote Transmitter:\n" + " Carrier Duty: %u%%\n" + " Non-blocking: %s", + this->carrier_duty_percent_, YESNO(this->non_blocking_)); + LOG_PIN(" Pin: ", this->pin_); +} + +// Writes the duty compare registers and sets the hardware CFG_UPDATA shadow-load bit; +// the new duty latches glitch-free at the next carrier period. ISR-safe: registers only. +// The group control word is shared with the paired channel, but every SDK write to it runs +// under GLOBAL_INT_DISABLE (bk_pwm), so it cannot be torn by this interrupt. +void RemoteTransmitterComponent::write_pwm_t1_(uint32_t t1_counts) { + const uint32_t group = this->pwm_channel_ / 2; + const uint32_t post = this->pwm_channel_ % 2; + const uint32_t group_base = REG_PWM_BASE + REG_PWM_GROUP_STRIDE * group; + auto *t_regs = (volatile uint32_t *) (group_base + REG_PWM_T_REGS[post]); + auto *ctrl = (volatile uint32_t *) group_base; + const uint32_t init_level_bit = 1UL << (8 * post + 6); // output level while the counter is stopped + const uint32_t cfg_updata_bit = 1UL << (8 * post + 7); // 0->1 latches T1..T4 at the next period + t_regs[0] = t1_counts; // T1: high time + t_regs[1] = 0; // T2 + t_regs[2] = 0; // T3 + t_regs[3] = this->isr_period_t4_; // T4: period + uint32_t cfg = *ctrl; + cfg &= ~(PWM_INT_STATUS_MASK | init_level_bit | cfg_updata_bit); + if (t1_counts != 0) + cfg |= init_level_bit; + *ctrl = cfg; + *ctrl = cfg | cfg_updata_bit; +} + +// --- envelope chain hooks (see remote_transmitter_libretiny_isr.cpp) --- + +bool RemoteTransmitterComponent::envelope_ready_() const { return this->pwm_channel_ >= 0; } + +// Recomputes the carrier period in 26MHz counts and stages the per-item duties; +// unmodulated protocols drive the pin constantly during marks +void RemoteTransmitterComponent::prepare_carrier_(uint32_t carrier_frequency) { + if (carrier_frequency > 0) { + this->isr_period_t4_ = std::max(uint32_t(2), (26000000UL + carrier_frequency / 2) / carrier_frequency); + } + uint32_t mark_t1 = (carrier_frequency > 0 && this->carrier_duty_percent_ < 100) + ? std::max(uint32_t(1), this->isr_period_t4_ * this->carrier_duty_percent_ / 100) + : this->isr_period_t4_; + uint32_t space_t1 = 0; + if (this->pin_->is_inverted()) { + mark_t1 = this->isr_period_t4_ - mark_t1; + space_t1 = this->isr_period_t4_; + } + this->isr_mark_t1_ = mark_t1; + this->isr_space_t1_ = space_t1; +} + +void RemoteTransmitterComponent::write_envelope_level_(bool mark) { + this->write_pwm_t1_(mark ? this->isr_mark_t1_ : this->isr_space_t1_); +} + +// The driver's microsecond init path is register writes under a nested interrupt guard, +// so it is safe to call from the chain's own interrupt +void RemoteTransmitterComponent::arm_one_shot_(uint32_t duration_us) { + timer_param_t param{}; + param.channel = ENVELOPE_TIMER; + param.div = 1; + param.period = duration_us; + param.t_Int_Handler = envelope_timer_isr; + sddev_control((char *) TIMER_DEV_NAME, CMD_TIMER_INIT_PARAM_US, ¶m); +} + +void RemoteTransmitterComponent::stop_envelope_timer_() { + UINT32 channel = ENVELOPE_TIMER; + sddev_control((char *) TIMER_DEV_NAME, CMD_TIMER_UNIT_DISABLE, &channel); +} + +void RemoteTransmitterComponent::digital_write(bool value) { + if (this->pwm_channel_ < 0) + return; + // serialize behind an in-flight chain, matching the ESP32/RMT non-blocking behavior + this->wait_until_idle_(); + this->write_pwm_t1_((value != this->pin_->is_inverted()) ? this->isr_period_t4_ : 0); +} + +#endif // REMOTE_TRANSMITTER_BK_PWM + +} // namespace esphome::remote_transmitter + +#endif // USE_BK72XX && !CLANG_TIDY diff --git a/esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp b/esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp new file mode 100644 index 0000000000..003cdfa986 --- /dev/null +++ b/esphome/components/remote_transmitter/remote_transmitter_libretiny_isr.cpp @@ -0,0 +1,224 @@ +#include "remote_transmitter.h" +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +// Envelope chain shared by the LibreTiny families that pace transmission from a hardware +// timer interrupt: RTL8720C (gtimer) and the BK7231N-style PWM block (BKTIMER1). Everything +// platform-specific sits behind five hooks implemented in the per-family files -- carrier +// setup, duty writes, one-shot arming and timer stop. Families without a usable timer keep +// the generic bit-bang implementation and compile none of this. +#if defined(USE_LIBRETINY_VARIANT_RTL8720C) || defined(REMOTE_TRANSMITTER_BK_PWM) + +namespace esphome::remote_transmitter { + +static const char *const TAG = "remote_transmitter"; + +// Margin past a transmission's expected duration before the chain is declared stalled +static constexpr uint32_t STALL_MARGIN_MS = 1000; +// Longest single one-shot armed; longer durations are chained. Both families need the cap: +// the Beken driver computes period_us * 26 in 32 bits (overflows past ~165s) and the Realtek +// us->tick conversion lives in mask ROM with unverified headroom. +static constexpr uint32_t MAX_ONE_SHOT_US = 50000; + +// One hardware timer is shared by all instances (MULTI_CONF), so they serialize on this +// token; the deadline always describes whichever chain currently owns it. +// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) +static RemoteTransmitterComponent *volatile s_active_transmitter = nullptr; +static uint32_t s_expected_end_ms = 0; +// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) + +// Entry point for trampolines whose SDK callback carries no user argument +void IRAM_ATTR RemoteTransmitterComponent::advance_active_isr() { + auto *transmitter = s_active_transmitter; + if (transmitter != nullptr) + transmitter->advance_envelope_isr(); +} + +// Arms the envelope timer, chaining durations longer than MAX_ONE_SHOT_US. ISR-safe. +void IRAM_ATTR RemoteTransmitterComponent::arm_envelope_timer_(uint32_t duration_us) { + // clamp to 1us (a zero-length one-shot never fires); the remainder must not underflow + const uint32_t chunk = std::max(uint32_t(1), std::min(duration_us, MAX_ONE_SHOT_US)); + this->isr_wait_remaining_ = duration_us > chunk ? duration_us - chunk : 0; + this->arm_one_shot_(chunk); +} + +// Writes the level for one envelope item and arms the timer for its duration. +// Runs in ISR context (and once from arm_chain_ to kick the chain): no logging, no allocation. +void IRAM_ATTR RemoteTransmitterComponent::start_isr_item_(size_t index) { + const int32_t item = this->isr_data_[index]; + this->write_envelope_level_(item > 0); + this->arm_envelope_timer_(uint32_t(item > 0 ? item : -item)); +} + +void IRAM_ATTR RemoteTransmitterComponent::advance_envelope_isr() { + if (!this->transmitting_) + return; // chain was aborted; this is a stale one-shot that was already latched + if (this->isr_wait_remaining_ > 0) { + // continue a duration longer than one hardware one-shot + this->arm_envelope_timer_(this->isr_wait_remaining_); + return; + } + if (this->isr_in_gap_) { + // inter-repeat gap elapsed; restart the item chain + this->isr_in_gap_ = false; + this->isr_index_ = 0; + this->start_isr_item_(0); + return; + } + this->isr_index_ = this->isr_index_ + 1; + if (this->isr_index_ < this->isr_data_.size()) { + this->start_isr_item_(this->isr_index_); + return; + } + // end of one repetition + this->write_envelope_level_(false); + if (this->isr_repeats_left_ > 1) { + this->isr_repeats_left_ = this->isr_repeats_left_ - 1; + this->isr_index_ = 0; + if (this->isr_send_wait_ > 0) { + this->isr_in_gap_ = true; + this->arm_envelope_timer_(this->isr_send_wait_); + } else { + this->start_isr_item_(0); + } + return; + } + // required on Beken (its timer reloads); on Realtek this only clears the enable bit of a + // one-shot that has already fired + this->stop_envelope_timer_(); + this->transmitting_ = false; + s_active_transmitter = nullptr; +} + +// Aborts a chain that stopped advancing: stop the timer, idle the pin, release the token. +// Every step is a no-op if the chain completed meanwhile. Task context only. +void RemoteTransmitterComponent::abort_stalled_chain_() { + // cleared first so a straggler one-shot bails at the ISR entry check + this->transmitting_ = false; + this->stop_envelope_timer_(); + this->write_envelope_level_(false); + s_active_transmitter = nullptr; + this->stall_aborted_ = true; + this->status_set_warning("envelope timer stalled"); + ESP_LOGE(TAG, "Envelope timer stalled; transmission aborted"); + delay(1); // let any already-latched interrupt land while the chain state is safe +} + +// Delivers one deferred completion with its status bookkeeping +void RemoteTransmitterComponent::deliver_completion_() { + if (!this->stall_aborted_) + this->status_clear_warning(); + this->complete_pending_ = false; + this->complete_trigger_.trigger(); +} + +// Waits until no chain is in flight, delivering any deferred completions; a completion +// automation may start a new send, so repeat until truly idle. Bounded by the stall deadline. +void RemoteTransmitterComponent::wait_until_idle_() { + while (true) { + while (true) { + // snapshot: the final ISR can clear the volatile pointer between a check and a use + auto *active = s_active_transmitter; + if (active == nullptr) + break; + if ((int32_t) (millis() - s_expected_end_ms) > 0) { + active->abort_stalled_chain_(); + break; + } + App.feed_wdt(); + delay(1); + } + if (!this->complete_pending_) + break; + this->deliver_completion_(); + } +} + +// Stages the repeat schedule and stall deadline, then starts the interrupt chain +void RemoteTransmitterComponent::arm_chain_(uint32_t send_times, uint32_t send_wait) { + this->isr_repeats_left_ = send_times; + this->isr_send_wait_ = send_wait; + this->isr_index_ = 0; + this->isr_in_gap_ = false; + this->stall_aborted_ = false; + uint64_t frame_us = 0; + for (int32_t item : this->isr_data_) + frame_us += uint32_t(item > 0 ? item : -item); + const uint64_t total_us = frame_us * send_times + uint64_t(send_wait) * (send_times - 1); + s_expected_end_ms = millis() + uint32_t(total_us / 1000) + STALL_MARGIN_MS; + this->transmitting_ = true; + s_active_transmitter = this; + this->start_isr_item_(0); +} + +void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) { + if (!this->envelope_ready_()) { + // both triggers still fire, so an on_complete-sequenced automation does not stall + ESP_LOGW(TAG, "Cannot send: PWM not initialized"); + this->transmit_trigger_.trigger(); + this->deliver_completion_(); + return; + } + this->wait_until_idle_(); + if (send_times == 0) { + // parity with the loop-based implementations: transmit nothing, but both triggers + // still fire so an on_complete-sequenced automation does not stall + this->transmit_trigger_.trigger(); + this->deliver_completion_(); + return; + } + ESP_LOGD(TAG, "Sending remote code"); + this->prepare_carrier_(this->temp_.get_carrier_frequency()); + // own copy: with non_blocking the caller may re-encode temp_ while this frame is in flight + this->isr_data_.assign(this->temp_.get_data().begin(), this->temp_.get_data().end()); + if (this->isr_data_.empty()) { + ESP_LOGW(TAG, "Empty data"); + this->transmit_trigger_.trigger(); + this->deliver_completion_(); + return; + } + // trigger first: the deadline computed in arm_chain_ must not be charged for user code + this->transmit_trigger_.trigger(); + // the automation may have started a send on another instance; let it finish before + // claiming the shared timer (a same-instance send remains unsupported here) + this->wait_until_idle_(); + this->arm_chain_(send_times, send_wait); + if (this->non_blocking_) { + this->complete_pending_ = true; + this->enable_loop(); + return; + } + // blocking mode: wait out the chain, bounded by the stall deadline + while (this->transmitting_) { + if ((int32_t) (millis() - s_expected_end_ms) > 0) { + this->abort_stalled_chain_(); + break; + } + App.feed_wdt(); + delay(1); + } + this->deliver_completion_(); +} + +void RemoteTransmitterComponent::loop() { + if (!this->complete_pending_) { + this->disable_loop(); + return; + } + if (this->transmitting_) { + // non-blocking stall recovery: without this, a dead chain would leave the carrier + // driven and on_complete unfired until the next send happened to abort it + if ((int32_t) (millis() - s_expected_end_ms) <= 0) + return; + this->abort_stalled_chain_(); + } + // release the loop before user code runs: the automation may start a new non-blocking + // send, and its enable_loop() must be the last writer or its completion would strand + this->disable_loop(); + this->deliver_completion_(); +} + +} // namespace esphome::remote_transmitter + +#endif // USE_LIBRETINY_VARIANT_RTL8720C || REMOTE_TRANSMITTER_BK_PWM diff --git a/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp b/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp index 9f629168f2..6db9faac36 100644 --- a/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter_rtl87xx.cpp @@ -24,20 +24,13 @@ static const char *const TAG = "remote_transmitter"; #ifdef USE_LIBRETINY_VARIANT_RTL8720C static constexpr uint32_t ENVELOPE_TIMER_ID = TIMER6; // GTimer7 -// Margin past a transmission's expected duration before the chain is declared stalled -static constexpr uint32_t STALL_MARGIN_MS = 1000; -// Longest single one-shot armed; longer durations are chained (ROM us->tick headroom unverified) -static constexpr uint32_t MAX_ONE_SHOT_US = 50000; -// Shared envelope timer: a second gtimer_init on the same id fails silently, so all -// instances serialize on s_active_transmitter +// One envelope timer for all instances: a second gtimer_init on the same id fails silently, +// so the chain serializes them (remote_transmitter_libretiny_isr.cpp) // NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables) static uint8_t s_pwm_tick_sources[] = {GTimer1, GTimer2, GTimer3, GTimer4, GTimer5, GTimer6, 0xff}; static gtimer_t s_envelope_timer; static bool s_envelope_timer_ready = false; -static RemoteTransmitterComponent *volatile s_active_transmitter = nullptr; -// Deadline for the in-flight transmission (millis-based); only touched from the main task -static uint32_t s_expected_end_ms = 0; // NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) static void IRAM_ATTR envelope_timer_isr(uint32_t arg) { @@ -104,105 +97,22 @@ void RemoteTransmitterComponent::digital_write(bool value) { } #ifdef USE_LIBRETINY_VARIANT_RTL8720C -// Arms the shared envelope timer, chaining durations longer than MAX_ONE_SHOT_US. ISR-safe. -void IRAM_ATTR RemoteTransmitterComponent::arm_envelope_timer_(uint32_t duration_us) { - // clamp to 1us (a zero-length one-shot never fires); the remainder must not underflow - const uint32_t chunk = std::max(uint32_t(1), std::min(duration_us, MAX_ONE_SHOT_US)); - this->isr_wait_remaining_ = duration_us > chunk ? duration_us - chunk : 0; - gtimer_start_one_shout(&s_envelope_timer, chunk, (void *) envelope_timer_isr, (uint32_t) this); -} +// --- envelope chain hooks (see remote_transmitter_libretiny_isr.cpp) --- -// Aborts a chain that stopped advancing: stop the timer, idle the pin, release the token. -// Every step is a no-op if the chain completed meanwhile. Task context only. -void RemoteTransmitterComponent::abort_stalled_chain_() { - // cleared first so a straggler one-shot bails at the ISR entry check - this->transmitting_ = false; - gtimer_stop(&s_envelope_timer); - pwmout_write(static_cast(this->pwm_), this->isr_space_duty_); - s_active_transmitter = nullptr; - this->stall_aborted_ = true; - this->status_set_warning("envelope timer stalled"); - ESP_LOGE(TAG, "Envelope timer stalled; transmission aborted"); - delay(1); // let any already-latched interrupt land while the chain state is safe -} +bool RemoteTransmitterComponent::envelope_ready_() const { return this->pwm_ != nullptr; } -// Delivers one deferred completion with its status bookkeeping -void RemoteTransmitterComponent::deliver_completion_() { - if (!this->stall_aborted_) - this->status_clear_warning(); - this->complete_pending_ = false; - this->complete_trigger_.trigger(); -} - -// Writes the duty for one envelope item and arms the timer for its duration. -// Runs in ISR context (and once from send_internal to kick the chain): no logging, no allocation. -void IRAM_ATTR RemoteTransmitterComponent::start_isr_item_(size_t index) { - const int32_t item = this->isr_data_[index]; - pwmout_write(static_cast(this->pwm_), item > 0 ? this->isr_mark_duty_ : this->isr_space_duty_); - this->arm_envelope_timer_(uint32_t(item > 0 ? item : -item)); -} - -void IRAM_ATTR RemoteTransmitterComponent::advance_envelope_isr() { - if (!this->transmitting_) - return; // chain was aborted; this is a stale one-shot that was already latched - if (this->isr_wait_remaining_ > 0) { - // continue a duration longer than one hardware one-shot - this->arm_envelope_timer_(this->isr_wait_remaining_); - return; +// Retunes the PWM period when the carrier changes and stages the per-item duties; +// unmodulated protocols (no carrier or 100% duty) drive the pin constantly during marks +void RemoteTransmitterComponent::prepare_carrier_(uint32_t carrier_frequency) { + float mark_duty = + (carrier_frequency > 0 && this->carrier_duty_percent_ < 100) ? this->carrier_duty_percent_ / 100.0f : 1.0f; + float space_duty = 0.0f; + if (this->pin_->is_inverted()) { + mark_duty = 1.0f - mark_duty; + space_duty = 1.0f; } - if (this->isr_in_gap_) { - // inter-repeat gap elapsed; restart the item chain - this->isr_in_gap_ = false; - this->isr_index_ = 0; - this->start_isr_item_(0); - return; - } - this->isr_index_++; - if (this->isr_index_ < this->isr_data_.size()) { - this->start_isr_item_(this->isr_index_); - return; - } - // end of one repetition - pwmout_write(static_cast(this->pwm_), this->isr_space_duty_); - if (this->isr_repeats_left_ > 1) { - this->isr_repeats_left_--; - this->isr_index_ = 0; - if (this->isr_send_wait_ > 0) { - this->isr_in_gap_ = true; - this->arm_envelope_timer_(this->isr_send_wait_); - } else { - this->start_isr_item_(0); - } - return; - } - this->transmitting_ = false; - s_active_transmitter = nullptr; -} - -// Waits until no chain is in flight, delivering any deferred completions; a completion -// automation may start a new send, so repeat until truly idle. Bounded by the stall deadline. -void RemoteTransmitterComponent::wait_until_idle_() { - while (true) { - while (true) { - // snapshot: the final ISR can clear the volatile pointer between a check and a use - auto *active = s_active_transmitter; - if (active == nullptr) - break; - if ((int32_t) (millis() - s_expected_end_ms) > 0) { - active->abort_stalled_chain_(); - break; - } - App.feed_wdt(); - delay(1); - } - if (!this->complete_pending_) - break; - this->deliver_completion_(); - } -} - -// Retunes the PWM period when the carrier changes; the ISR sets duty per item -void RemoteTransmitterComponent::update_carrier_(uint32_t carrier_frequency) { + this->isr_mark_duty_ = mark_duty; + this->isr_space_duty_ = space_duty; if (carrier_frequency == 0 || carrier_frequency == this->current_carrier_frequency_) return; // round(1000000/freq), clamped so a bad lambda can't hand the SDK a zero period @@ -211,97 +121,15 @@ void RemoteTransmitterComponent::update_carrier_(uint32_t carrier_frequency) { this->current_carrier_frequency_ = carrier_frequency; } -// Stages the repeat schedule and stall deadline, then starts the interrupt chain -void RemoteTransmitterComponent::arm_chain_(uint32_t send_times, uint32_t send_wait) { - this->isr_repeats_left_ = send_times; - this->isr_send_wait_ = send_wait; - this->isr_index_ = 0; - this->isr_in_gap_ = false; - this->stall_aborted_ = false; - uint64_t frame_us = 0; - for (int32_t item : this->isr_data_) - frame_us += uint32_t(item > 0 ? item : -item); - const uint64_t total_us = frame_us * send_times + uint64_t(send_wait) * (send_times - 1); - s_expected_end_ms = millis() + uint32_t(total_us / 1000) + STALL_MARGIN_MS; - this->transmitting_ = true; - s_active_transmitter = this; - this->start_isr_item_(0); +void IRAM_ATTR RemoteTransmitterComponent::write_envelope_level_(bool mark) { + pwmout_write(static_cast(this->pwm_), mark ? this->isr_mark_duty_ : this->isr_space_duty_); } -void RemoteTransmitterComponent::send_internal(uint32_t send_times, uint32_t send_wait) { - if (this->pwm_ == nullptr) { - ESP_LOGW(TAG, "Cannot send: PWM not initialized"); - return; - } - this->wait_until_idle_(); - if (send_times == 0) { - // parity with the loop-based implementations: transmit nothing, but both triggers - // still fire so an on_complete-sequenced automation does not stall - this->transmit_trigger_.trigger(); - this->deliver_completion_(); - return; - } - ESP_LOGD(TAG, "Sending remote code"); - const uint32_t carrier_frequency = this->temp_.get_carrier_frequency(); - // unmodulated protocols (no carrier or 100% duty) drive the pin constantly during marks - float mark_duty = - (carrier_frequency > 0 && this->carrier_duty_percent_ < 100) ? this->carrier_duty_percent_ / 100.0f : 1.0f; - float space_duty = 0.0f; - if (this->pin_->is_inverted()) { - mark_duty = 1.0f - mark_duty; - space_duty = 1.0f; - } - this->update_carrier_(carrier_frequency); - // own copy: with non_blocking the caller may re-encode temp_ while this frame is in flight - this->isr_data_.assign(this->temp_.get_data().begin(), this->temp_.get_data().end()); - if (this->isr_data_.empty()) { - ESP_LOGW(TAG, "Empty data"); - this->transmit_trigger_.trigger(); - this->deliver_completion_(); - return; - } - this->isr_mark_duty_ = mark_duty; - this->isr_space_duty_ = space_duty; - // trigger first: the deadline computed in arm_chain_ must not be charged for user code - this->transmit_trigger_.trigger(); - // the automation may have started a send on another instance; let it finish before - // claiming the shared timer (a same-instance send remains unsupported here) - this->wait_until_idle_(); - this->arm_chain_(send_times, send_wait); - if (this->non_blocking_) { - this->complete_pending_ = true; - this->enable_loop(); - return; - } - // blocking mode: wait out the chain, bounded by the stall deadline - while (this->transmitting_) { - if ((int32_t) (millis() - s_expected_end_ms) > 0) { - this->abort_stalled_chain_(); - break; - } - App.feed_wdt(); - delay(1); - } - this->deliver_completion_(); +void IRAM_ATTR RemoteTransmitterComponent::arm_one_shot_(uint32_t duration_us) { + gtimer_start_one_shout(&s_envelope_timer, duration_us, (void *) envelope_timer_isr, (uint32_t) this); } -void RemoteTransmitterComponent::loop() { - if (!this->complete_pending_) { - this->disable_loop(); - return; - } - if (this->transmitting_) { - // non-blocking stall recovery: without this, a dead chain would leave the carrier - // driven and on_complete unfired until the next send happened to abort it - if ((int32_t) (millis() - s_expected_end_ms) <= 0) - return; - this->abort_stalled_chain_(); - } - // release the loop before user code runs: the automation may start a new non-blocking - // send, and its enable_loop() must be the last writer or its completion would strand - this->disable_loop(); - this->deliver_completion_(); -} +void IRAM_ATTR RemoteTransmitterComponent::stop_envelope_timer_() { gtimer_stop(&s_envelope_timer); } #else // !USE_LIBRETINY_VARIANT_RTL8720C -- AmebaZ (RTL8710B): spin-based envelope, per-frame priority boost diff --git a/tests/component_tests/remote_transmitter/test_non_blocking_gate.py b/tests/component_tests/remote_transmitter/test_non_blocking_gate.py index f843f1e84f..ee2769e177 100644 --- a/tests/component_tests/remote_transmitter/test_non_blocking_gate.py +++ b/tests/component_tests/remote_transmitter/test_non_blocking_gate.py @@ -4,6 +4,9 @@ the ISR paths, so this gate is the only CI-reachable coverage for the platform m import pytest from esphome.components.libretiny.const import ( + FAMILY_BK7231N, + FAMILY_BK7231T, + FAMILY_BK7238, FAMILY_RTL8710B, FAMILY_RTL8720C, KEY_FAMILY, @@ -23,6 +26,9 @@ from ..types import SetCoreConfigCallable (PlatformFramework.ESP32_IDF, None, True), (PlatformFramework.RTL87XX_ARDUINO, FAMILY_RTL8720C, True), (PlatformFramework.RTL87XX_ARDUINO, FAMILY_RTL8710B, False), + (PlatformFramework.BK72XX_ARDUINO, FAMILY_BK7231N, True), + (PlatformFramework.BK72XX_ARDUINO, FAMILY_BK7238, True), + (PlatformFramework.BK72XX_ARDUINO, FAMILY_BK7231T, False), (PlatformFramework.ESP8266_ARDUINO, None, False), ], ) diff --git a/tests/components/remote_transmitter/test.bk72xx-ard.yaml b/tests/components/remote_transmitter/test.bk72xx-ard.yaml index 2a5cceddec..ea2feafda9 100644 --- a/tests/components/remote_transmitter/test.bk72xx-ard.yaml +++ b/tests/components/remote_transmitter/test.bk72xx-ard.yaml @@ -2,6 +2,7 @@ remote_transmitter: id: xmitr pin: GPIO26 carrier_duty_percent: 50% + # non_blocking is bk7231n/bk7238-only; the CI board is a BK7252 packages: buttons: !include common-buttons.yaml From 5328813814b52178e296a8cb8122825e98a0deaf Mon Sep 17 00:00:00 2001 From: MakerYuichi <106516578+MakerYuichi@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:13:55 +0530 Subject: [PATCH 20/30] [time] Silence compiler warning by initializing transit variables (#18715) (#18723) Co-authored-by: doraemon2200 <106516578+doraemon2200@users.noreply.github.com> --- esphome/components/time/posix_tz.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp index 188df599f6..002aadfec3 100644 --- a/esphome/components/time/posix_tz.cpp +++ b/esphome/components/time/posix_tz.cpp @@ -178,7 +178,8 @@ static int __attribute__((noinline)) days_from_year_start(int year, int month, i } time_t __attribute__((noinline)) calculate_dst_transition(int year, const DSTRule &rule, int32_t base_offset_seconds) { - int month, day; + int month = 1; + int day = 1; switch (rule.type) { case DSTRuleType::MONTH_WEEK_DAY: { From 8a89f3075f31b3bf0db104551fb566312528b872 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Metrich?= <45318189+FredM67@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:03:10 +0200 Subject: [PATCH 21/30] [emontx] Fix sensor storage initialization ordering (#18771) --- esphome/components/emontx/__init__.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/esphome/components/emontx/__init__.py b/esphome/components/emontx/__init__.py index 7dde794f0b..3821f3e10e 100644 --- a/esphome/components/emontx/__init__.py +++ b/esphome/components/emontx/__init__.py @@ -116,14 +116,16 @@ _CALLBACK_AUTOMATIONS = ( async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await uart.register_uart_device(var, config) - # Initialize sensor storage with count from final_validate + # Initialize sensor storage with count from final_validate before any + # await, so platform to_code() calls always see it initialized + # regardless of YAML key order. sensor_count = _get_data().sensor_counts.get(str(config[CONF_ID]), 0) if sensor_count > 0: cg.add(var.init_sensors(sensor_count)) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) From 34e74536774b0b031f1f69bc166fb86ffc6c7746 Mon Sep 17 00:00:00 2001 From: Leonardo Rivera Date: Wed, 26 Aug 2026 15:26:31 -0300 Subject: [PATCH 22/30] [climate] Don't restore a saved mode the device no longer supports (#18296) --- esphome/components/climate/climate.cpp | 9 ++- tests/components/climate/climate_test.cpp | 73 +++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 tests/components/climate/climate_test.cpp diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index 0f01443bd0..6ca9e394f7 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -551,7 +551,14 @@ ClimateCall ClimateDeviceRestoreState::to_call(Climate *climate) { void ClimateDeviceRestoreState::apply(Climate *climate) { auto traits = climate->get_traits(); - climate->mode = this->mode; + // A saved mode the device no longer offers cannot be selected again, so skip it and leave the + // entity on the mode it already has. The other saved fields are still restored. + if (traits.supports_mode(this->mode)) { + climate->mode = this->mode; + } else { + ESP_LOGW(TAG, "'%s' - Saved mode %s is no longer supported, keeping %s", climate->get_name().c_str(), + LOG_STR_ARG(climate_mode_to_string(this->mode)), LOG_STR_ARG(climate_mode_to_string(climate->mode))); + } if (traits.has_feature_flags(CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE | CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) { climate->target_temperature_low = this->target_temperature_low; diff --git a/tests/components/climate/climate_test.cpp b/tests/components/climate/climate_test.cpp new file mode 100644 index 0000000000..bda014b87a --- /dev/null +++ b/tests/components/climate/climate_test.cpp @@ -0,0 +1,73 @@ +#include +#include "esphome/components/climate/climate.h" + +namespace esphome::climate::testing { + +// Minimal concrete Climate that offers a fixed set of modes, so the restore path can be exercised +// without any hardware or platform component. +class TestClimate : public Climate { + public: + ClimateTraits traits() override { + auto traits = ClimateTraits(); + traits.set_supported_modes({CLIMATE_MODE_OFF, CLIMATE_MODE_COOL}); + traits.set_supported_fan_modes({CLIMATE_FAN_LOW, CLIMATE_FAN_HIGH}); + return traits; + } + + protected: + void control(const ClimateCall &call) override {} +}; + +TEST(ClimateRestoreStateTest, RestoresASupportedMode) { + TestClimate climate; + // Value-initialized: several members (mode, swing_mode, the temperature union) have no default + // member initializer, so leaving the {} off would read indeterminate values. + ClimateDeviceRestoreState state{}; + state.mode = CLIMATE_MODE_COOL; + + state.apply(&climate); + + EXPECT_EQ(climate.mode, CLIMATE_MODE_COOL); +} + +TEST(ClimateRestoreStateTest, DoesNotRestoreAnUnsupportedMode) { + TestClimate climate; + ClimateDeviceRestoreState state{}; + state.mode = CLIMATE_MODE_HEAT; + + state.apply(&climate); + + // The device never advertised HEAT, so the mode stays where it was. + EXPECT_EQ(climate.mode, CLIMATE_MODE_OFF); +} + +TEST(ClimateRestoreStateTest, LeavesTheCurrentModeAloneRatherThanForcingOff) { + TestClimate climate; + // apply() is public and nothing restricts it to setup(), so the entity is not necessarily off + // when an unsupported mode is dropped. It keeps what it had rather than being forced to OFF. + climate.mode = CLIMATE_MODE_COOL; + ClimateDeviceRestoreState state{}; + state.mode = CLIMATE_MODE_HEAT; + + state.apply(&climate); + + EXPECT_EQ(climate.mode, CLIMATE_MODE_COOL); +} + +TEST(ClimateRestoreStateTest, KeepsRestoringTheOtherFieldsWhenTheModeIsDropped) { + TestClimate climate; + ClimateDeviceRestoreState state{}; + state.mode = CLIMATE_MODE_HEAT; + state.target_temperature = 21.0f; + state.uses_custom_fan_mode = false; + state.fan_mode = CLIMATE_FAN_HIGH; + + state.apply(&climate); + + EXPECT_EQ(climate.mode, CLIMATE_MODE_OFF); + EXPECT_FLOAT_EQ(climate.target_temperature, 21.0f); + // Compared as an optional: this asserts both that the fan mode was restored and what it holds. + EXPECT_EQ(climate.fan_mode, CLIMATE_FAN_HIGH); +} + +} // namespace esphome::climate::testing From f17ef133f34be727df30f6cebf3e80f2af515ff3 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Wed, 26 Aug 2026 20:27:16 +0200 Subject: [PATCH 23/30] [hoermann_hcp] Add buttons to hoermann_hcp (#18544) --- .../hoermann_hcp/button/__init__.py | 43 +++++++++++ .../hoermann_hcp/button/hoermann_hcp_button.h | 34 +++++++++ .../components/hoermann_hcp/hoermann_hcp.cpp | 5 ++ .../components/hoermann_hcp/hoermann_hcp.h | 6 +- .../button/hoermann_hcp_button_test.cpp | 72 +++++++++++++++++++ tests/components/hoermann_hcp/common.yaml | 7 ++ 6 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 esphome/components/hoermann_hcp/button/__init__.py create mode 100644 esphome/components/hoermann_hcp/button/hoermann_hcp_button.h create mode 100644 tests/components/hoermann_hcp/button/hoermann_hcp_button_test.cpp diff --git a/esphome/components/hoermann_hcp/button/__init__.py b/esphome/components/hoermann_hcp/button/__init__.py new file mode 100644 index 0000000000..dc2efcec44 --- /dev/null +++ b/esphome/components/hoermann_hcp/button/__init__.py @@ -0,0 +1,43 @@ +import esphome.codegen as cg +from esphome.components import button +import esphome.config_validation as cv +from esphome.const import ICON_AIR_FILTER +from esphome.types import ConfigType + +from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns + +DEPENDENCIES = ["hoermann_hcp"] + +CONF_HALF_OPEN = "half_open" +CONF_VENT = "vent" + +ICON_GARAGE_OPEN_VARIANT = "mdi:garage-open-variant" + +HoermannHcpVentButton = hoermann_hcp_ns.class_("HoermannHcpVentButton", button.Button) +HoermannHcpHalfOpenButton = hoermann_hcp_ns.class_( + "HoermannHcpHalfOpenButton", button.Button +) + +BUTTON_KEYS = (CONF_VENT, CONF_HALF_OPEN) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp), + cv.Optional(CONF_VENT): button.button_schema( + HoermannHcpVentButton, icon=ICON_AIR_FILTER + ), + cv.Optional(CONF_HALF_OPEN): button.button_schema( + HoermannHcpHalfOpenButton, icon=ICON_GARAGE_OPEN_VARIANT + ), + } + ), + cv.has_at_least_one_key(*BUTTON_KEYS), +) + + +async def to_code(config: ConfigType) -> None: + parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID]) + for key in BUTTON_KEYS: + if (conf := config.get(key)) is not None: + await button.new_button(conf, parent) diff --git a/esphome/components/hoermann_hcp/button/hoermann_hcp_button.h b/esphome/components/hoermann_hcp/button/hoermann_hcp_button.h new file mode 100644 index 0000000000..e9ebceee88 --- /dev/null +++ b/esphome/components/hoermann_hcp/button/hoermann_hcp_button.h @@ -0,0 +1,34 @@ +#pragma once + +#include "esphome/components/button/button.h" +#include "../hoermann_hcp.h" + +namespace esphome::hoermann_hcp { + +// The door commands the cover has no equivalent for. A refused command is already reported by the hub and +// leaves nothing to correct here, because a button carries no state of its own. +class HoermannHcpButton : public button::Button { + public: + explicit HoermannHcpButton(HoermannHcp *parent) : parent_(parent) {} + + protected: + HoermannHcp *const parent_; +}; + +class HoermannHcpVentButton final : public HoermannHcpButton { + public: + using HoermannHcpButton::HoermannHcpButton; + + protected: + void press_action() override { this->parent_->vent_door(); } +}; + +class HoermannHcpHalfOpenButton final : public HoermannHcpButton { + public: + using HoermannHcpButton::HoermannHcpButton; + + protected: + void press_action() override { this->parent_->half_open_door(); } +}; + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.cpp b/esphome/components/hoermann_hcp/hoermann_hcp.cpp index a780854831..17df927eb7 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.cpp +++ b/esphome/components/hoermann_hcp/hoermann_hcp.cpp @@ -22,6 +22,9 @@ static constexpr uint8_t MAX_LIGHT_TOGGLES_IN_FLIGHT = 4; static constexpr HoermannHcpCommand COMMAND_OPEN{"open", 0x0210, 0x0110}; static constexpr HoermannHcpCommand COMMAND_CLOSE{"close", 0x0220, 0x0120}; static constexpr HoermannHcpCommand COMMAND_IMPULSE{"impulse", 0x0240, 0x0140}; +// The intermediate positions are named in the second register, so the first only carries the phase. +static constexpr HoermannHcpCommand COMMAND_VENT{"vent", 0x0200, 0x0100, 0x4000, 0x4000}; +static constexpr HoermannHcpCommand COMMAND_HALF_OPEN{"half open", 0x0200, 0x0100, 0x0400, 0x0400}; // The lamp is named in the second register, but its phase bytes follow no scheme the door commands share. static constexpr HoermannHcpCommand COMMAND_TOGGLE_LAMP{"toggle light", 0x0100, 0x0800, 0x0200, 0x0200, false}; @@ -286,6 +289,8 @@ bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) { bool HoermannHcp::open_door() { return this->queue_command_(COMMAND_OPEN); } bool HoermannHcp::close_door() { return this->queue_command_(COMMAND_CLOSE); } bool HoermannHcp::impulse_door() { return this->queue_command_(COMMAND_IMPULSE); } +bool HoermannHcp::vent_door() { return this->queue_command_(COMMAND_VENT); } +bool HoermannHcp::half_open_door() { return this->queue_command_(COMMAND_HALF_OPEN); } bool HoermannHcp::toggle_light() { if (this->light_toggles_in_flight_ >= MAX_LIGHT_TOGGLES_IN_FLIGHT) { ESP_LOGW(TAG, "Too many lamp toggles are still waiting to be confirmed, dropping this one"); diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.h b/esphome/components/hoermann_hcp/hoermann_hcp.h index 41fd7617e4..83be385c7b 100644 --- a/esphome/components/hoermann_hcp/hoermann_hcp.h +++ b/esphome/components/hoermann_hcp/hoermann_hcp.h @@ -22,7 +22,8 @@ enum class DoorState : uint8_t { }; // A HCP command is a simulated key press: the pressed value is presented to the bus controller, then after a -// short delay the released value. Each half also carries a second register, which only the lamp command uses. +// short delay the released value. Each half also carries a second register, which names the buttons that do +// not fit into the first. struct HoermannHcpCommand { const char *name; uint16_t pressed_value; @@ -54,6 +55,9 @@ class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { bool open_door(); bool close_door(); bool impulse_door(); + // The door drives to these intermediate positions on its own, so neither takes a target to be stopped at. + bool vent_door(); + bool half_open_door(); bool stop_door(); bool set_position(float position); bool toggle_light(); diff --git a/tests/components/hoermann_hcp/button/hoermann_hcp_button_test.cpp b/tests/components/hoermann_hcp/button/hoermann_hcp_button_test.cpp new file mode 100644 index 0000000000..9c38bc1708 --- /dev/null +++ b/tests/components/hoermann_hcp/button/hoermann_hcp_button_test.cpp @@ -0,0 +1,72 @@ +#include + +#include "esphome/components/hoermann_hcp/button/hoermann_hcp_button.h" + +#include "../common.h" + +namespace esphome::hoermann_hcp::testing { + +// The intermediate positions are named in the second register, which repeats that name on release. +TEST(HoermannHcpButtonTest, VentButtonSendsTheVentCommand) { + TestableHoermannHcp door; + HoermannHcpVentButton vent(&door); + connect_controller(door); + + vent.press(); + + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0200); + EXPECT_EQ(pressed_2, 0x4000); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + auto [released, released_2] = poll_command(door); + EXPECT_EQ(released, 0x0100); + EXPECT_EQ(released_2, 0x4000); +} + +TEST(HoermannHcpButtonTest, HalfOpenButtonSendsTheHalfOpenCommand) { + TestableHoermannHcp door; + HoermannHcpHalfOpenButton half_open(&door); + connect_controller(door); + + half_open.press(); + + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0200); + EXPECT_EQ(pressed_2, 0x0400); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + auto [released, released_2] = poll_command(door); + EXPECT_EQ(released, 0x0100); + EXPECT_EQ(released_2, 0x0400); +} + +// The door drives to the vent position on its own, so a position the cover was still travelling to must not +// stop it on the way there. +TEST(HoermannHcpButtonTest, VentAbandonsAnArmedTarget) { + TestableHoermannHcp door; // starts out fully closed + HoermannHcpVentButton vent(&door); + connect_controller(door); + door.set_position(0.5f); + consume_command(door); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100})); + ASSERT_EQ(door.get_door_state(), DoorState::OPENING); + + vent.press(); + consume_command(door); + + // Position 120/200 = 0.6 is past the abandoned target, which must no longer stop the door. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + EXPECT_EQ(poll_command(door).first, 0x0000); +} + +// A button carries no state, so a refused press is simply dropped rather than fired once the controller +// turns up, which could be much later. +TEST(HoermannHcpButtonTest, PressWithoutABusControllerSendsNothing) { + HoermannHcp door; // never contacted by a bus controller + HoermannHcpVentButton vent(&door); + + vent.press(); + + EXPECT_EQ(poll_command(door).first, 0x0000); +} + +} // namespace esphome::hoermann_hcp::testing diff --git a/tests/components/hoermann_hcp/common.yaml b/tests/components/hoermann_hcp/common.yaml index 552b1cb0fd..618a8181bf 100644 --- a/tests/components/hoermann_hcp/common.yaml +++ b/tests/components/hoermann_hcp/common.yaml @@ -12,6 +12,13 @@ binary_sensor: is_connected: name: Garage Connected +button: + - platform: hoermann_hcp + vent: + name: Garage Vent + half_open: + name: Garage Half Open + light: - platform: hoermann_hcp name: Garage Light From d28b17c619937f13353fb1857f52239a89eb8735 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:26:45 +0000 Subject: [PATCH 24/30] Bump filelock from 3.32.3 to 3.32.4 (#18799) Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3d4439bf10..1a98c2a8e4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,7 +29,7 @@ requests==2.34.2 py7zr==1.1.3 platformdirs==4.11.3 # native esp-idf toolchain global cache dir ninja==1.13.0 # native esp8266 arduino toolchain build driver -filelock==3.32.3 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg +filelock==3.32.4 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 From fedd46e648a3099915476e4d47c357e946e3eafd Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:55:29 +0000 Subject: [PATCH 25/30] Bump aioesphomeapi from 46.2.0 to 46.2.1 (#18804) --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1a98c2a8e4..de00f07836 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==46.2.0 +aioesphomeapi==46.2.1 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 From 5878406918eda313684cca4c9f2e0fa737358a47 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:33:52 -0500 Subject: [PATCH 26/30] Bump bundled esphome-device-builder to 1.13.1 (#18807) --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index d46f01838e..0da8048c57 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ RUN \ -r /requirements.txt # Install the ESPHome Device Builder dashboard. -RUN uv pip install --no-cache-dir esphome-device-builder==1.13.0 +RUN uv pip install --no-cache-dir esphome-device-builder==1.13.1 RUN \ platformio settings set enable_telemetry No \ From 950816579764d38865153c1738f473800df64882 Mon Sep 17 00:00:00 2001 From: guillempages Date: Wed, 26 Aug 2026 23:36:47 +0200 Subject: [PATCH 27/30] [runtime_image] Add check for dimensions in BMP (#18800) --- esphome/components/runtime_image/bmp_decoder.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/esphome/components/runtime_image/bmp_decoder.cpp b/esphome/components/runtime_image/bmp_decoder.cpp index 5d45621fb7..204d6cc14b 100644 --- a/esphome/components/runtime_image/bmp_decoder.cpp +++ b/esphome/components/runtime_image/bmp_decoder.cpp @@ -80,6 +80,10 @@ int HOT BmpDecoder::decode(uint8_t *buffer, size_t size) { this->width_ = encode_uint32(buffer[21], buffer[20], buffer[19], buffer[18]); this->height_ = encode_uint32(buffer[25], buffer[24], buffer[23], buffer[22]); + if (this->width_ <= 0 || this->height_ <= 0) { + ESP_LOGE(TAG, "Invalid image dimensions: (%zdx%zd)", this->width_, this->height_); + return DECODE_ERROR_UNSUPPORTED_FORMAT; + } this->bits_per_pixel_ = encode_uint16(buffer[29], buffer[28]); this->compression_method_ = encode_uint32(buffer[33], buffer[32], buffer[31], buffer[30]); this->image_data_size_ = encode_uint32(buffer[37], buffer[36], buffer[35], buffer[34]); From 5df1c7f1d3e2df2c5d4355c1cde8f9882c6b8b25 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Wed, 26 Aug 2026 16:24:09 -0700 Subject: [PATCH 28/30] [modbus_controller] Writer entities as their own hub device; heap-free, byte-accurate write path (#18082) Co-authored-by: J. Nick Koston --- esphome/components/modbus/helpers.py | 3 + .../components/modbus_controller/__init__.py | 30 +++-- .../modbus_controller/modbus_controller.cpp | 67 ++++++++++ .../modbus_controller/modbus_controller.h | 118 +++++++++++++++++- .../modbus_controller/number/__init__.py | 10 +- .../number/modbus_number.cpp | 98 ++++++++------- .../modbus_controller/number/modbus_number.h | 7 +- .../modbus_controller/output/__init__.py | 13 +- .../output/modbus_output.cpp | 115 +++++++++-------- .../modbus_controller/output/modbus_output.h | 24 ++-- .../modbus_controller/select/__init__.py | 20 ++- .../select/modbus_select.cpp | 55 ++++---- .../modbus_controller/select/modbus_select.h | 7 +- .../modbus_controller/switch/__init__.py | 7 +- .../switch/modbus_switch.cpp | 93 +++++++------- .../modbus_controller/switch/modbus_switch.h | 7 +- .../command_payload_test.cpp | 4 +- .../uart_mock_modbus_lambda_write.yaml | 97 ++++++++++++++ tests/integration/test_uart_mock_modbus.py | 58 ++++++--- 19 files changed, 598 insertions(+), 235 deletions(-) create mode 100644 tests/integration/fixtures/uart_mock_modbus_lambda_write.yaml diff --git a/esphome/components/modbus/helpers.py b/esphome/components/modbus/helpers.py index e7eaacee0c..ec95b82045 100644 --- a/esphome/components/modbus/helpers.py +++ b/esphome/components/modbus/helpers.py @@ -3,6 +3,9 @@ import esphome.codegen as cg modbus_ns = cg.esphome_ns.namespace("modbus") modbus_helpers_ns = modbus_ns.namespace("helpers") +RegisterValues = modbus_ns.class_("RegisterValues") +PduBuffer = modbus_helpers_ns.class_("PduBuffer") + FunctionCode_ns = modbus_ns.namespace("FunctionCode") FunctionCode = FunctionCode_ns.enum("FunctionCode") diff --git a/esphome/components/modbus_controller/__init__.py b/esphome/components/modbus_controller/__init__.py index 924a260d37..e87eccb32c 100644 --- a/esphome/components/modbus_controller/__init__.py +++ b/esphome/components/modbus_controller/__init__.py @@ -191,7 +191,7 @@ ModbusItemBaseSchema = cv.Schema( ) -def validate_modbus_register(config): +def validate_modbus_register(config: ConfigType) -> ConfigType: # custom_command is the deprecated alias for custom_pdu (migrated later in final validate); treat # either as "a custom frame is configured" so the address/register_type rules match. has_custom = CONF_CUSTOM_PDU in config or CONF_CUSTOM_COMMAND in config @@ -278,7 +278,7 @@ def _final_validate(config: ConfigType) -> None: FINAL_VALIDATE_SCHEMA = _final_validate -def modbus_calc_properties(config): +def modbus_calc_properties(config: ConfigType) -> tuple[int, int]: byte_offset = 0 reg_count = 0 if CONF_OFFSET in config: @@ -307,8 +307,12 @@ def modbus_calc_properties(config): async def add_modbus_base_properties( - var, config, sensor_type, lambda_param_type=cg.float_, lambda_return_type=float -): + var: cg.MockObj, + config: ConfigType, + sensor_type: cg.MockObjClass, + lambda_param_type: cg.MockObj = cg.float_, + lambda_return_type: Any = float, +) -> None: if CONF_CUSTOM_PDU in config: cg.add(var.set_custom_pdu(config[CONF_CUSTOM_PDU])) @@ -347,8 +351,11 @@ _CALLBACK_AUTOMATIONS = ( ) -async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) +async def to_code(config: ConfigType) -> None: + # Await the hub first, so no entity can bind to a controller that doesn't have one yet. + hub = await cg.get_variable(config[modbus.CONF_MODBUS_ID]) + var = cg.new_Pvariable(config[CONF_ID], hub, config[CONF_ADDRESS]) + await cg.register_component(var, config) cg.add(var.set_max_cmd_retries(config[CONF_MAX_CMD_RETRIES])) cg.add(var.set_offline_skip_updates(config[CONF_OFFLINE_SKIP_UPDATES])) cg.add( @@ -356,17 +363,22 @@ async def to_code(config): modbus.command_options_expression(config, direction="read") ) ) - await register_modbus_device(var, config) await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) -async def register_modbus_device(var, config): +async def register_modbus_device(var: cg.MockObj, config: ConfigType) -> cg.MockObj: + # Remove before 2027.3.0 + _LOGGER.warning( + "'modbus_controller.register_modbus_device' is deprecated, use " + "'modbus.register_modbus_client_device' and set the address on your own " + "class instead. Will be removed in 2027.3.0" + ) cg.add(var.set_address(config[CONF_ADDRESS])) await cg.register_component(var, config) return await modbus.register_modbus_client_device(var, config) -def function_code_to_register(function_code): +def function_code_to_register(function_code: str) -> cg.MockObj: FUNCTION_CODE_TYPE_MAP = { "read_coils": EntityType.COIL, "read_discrete_inputs": EntityType.DISCRETE_INPUT, diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index 20b8f516e9..9d7b719e15 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -10,6 +10,73 @@ static const char *const TAG = "modbus_controller"; void ModbusController::setup() { this->create_polling_commands_(); } +void WriterDevice::warn_write_buffer_deprecated(const LogString *platform, uint16_t address) { + if (this->write_buffer_deprecated_warned_) + return; + this->write_buffer_deprecated_warned_ = true; + ESP_LOGW(TAG, + "Modbus %s (address 0x%X): filling the write_lambda buffer parameter is deprecated; call a write helper / " + "queue_pdu() on the entity (item) instead. The buffer parameter is removed in 2027.3.0", + LOG_STR_ARG(platform), address); +} + +bool WriterDevice::send_raw_frame_deprecated(std::span frame) { + if (frame.empty()) + return false; + this->dispatched_ = true; + return this->parent_->queue_pdu(frame[0], frame.subspan(1), this); +} + +void WriterDevice::set_controller(ModbusController *controller) { + this->controller_ = controller; + this->set_parent(controller->hub()); + this->set_address(controller->device_address()); +} + +void WriterDevice::notify_online_(std::span request_pdu) { + if (this->controller_ != nullptr) + this->controller_->set_online(true, fc_of(request_pdu), addr_of(request_pdu)); +} + +void WriterDevice::on_response(std::span request_pdu, std::span response_pdu) { + this->notify_online_(request_pdu); + this->dispatch_response_(request_pdu, response_pdu, std::nullopt); +} + +void WriterDevice::on_error(std::span request_pdu, modbus::ExceptionCode exception_code) { + ESP_LOGW(TAG, "Modbus error function code: 0x%X register 0x%X exception: %d", fc_of(request_pdu), + addr_of(request_pdu), static_cast(exception_code)); + this->notify_online_(request_pdu); // an exception is still a legitimate reply -> device is online + this->dispatch_response_(request_pdu, {}, exception_code); +} + +// Fired once per wire transmission (including hub re-queues from a retry), so the on_command_sent trigger +// reflects when the frame actually went out, not when it was queued. +void WriterDevice::on_sent(std::span request_pdu) { + if (this->controller_ != nullptr) + this->controller_->command_sent(fc_of(request_pdu), addr_of(request_pdu)); +} + +void WriterDevice::on_not_sent(std::span request_pdu) { + // Only the offline teardown reaches this (a supersede retires silently), so the frame is genuinely + // lost; a dropped write was already published optimistically, so surface it. + if (modbus::helpers::is_function_code_write(fc_of(request_pdu))) { + ESP_LOGW(TAG, "Write not sent: function 0x%X register 0x%X", fc_of(request_pdu), addr_of(request_pdu)); + } else { + ESP_LOGD(TAG, "Request not sent: function 0x%X register 0x%X", fc_of(request_pdu), addr_of(request_pdu)); + } +} + +bool WriterDevice::on_no_response(std::span request_pdu) { + if (this->controller_ == nullptr) + return false; + this->controller_->increment_non_response_count(); + if (this->controller_->can_send()) + return true; // the hub re-queues the frame it is holding; on_sent fires again on the retry + this->controller_->set_online(false, fc_of(request_pdu), addr_of(request_pdu)); + return false; +} + ModbusCommandItem::ModbusCommandItem(ModbusController &controller, modbus::ModbusClientHub *parent, uint8_t address, RegisterRange &&range) : modbus::ModbusClientDevice(parent, address), diff --git a/esphome/components/modbus_controller/modbus_controller.h b/esphome/components/modbus_controller/modbus_controller.h index 1db07f1ee8..1f36d5a7c8 100644 --- a/esphome/components/modbus_controller/modbus_controller.h +++ b/esphome/components/modbus_controller/modbus_controller.h @@ -232,6 +232,115 @@ struct RegisterRange { SensorSet sensors; // all sensors of this range }; +/// A hub device owned by a writer entity (switch/number/select/output) through WriterEntity. +/// Centralises the feedback to the controller - online/offline tracking, retry counting and the +/// on_command_sent trigger - and records every dispatch, so a write lambda can tell "I sent it myself" +/// from "use the default write". The hub base is inherited protected, so the public members below are +/// the entity's whole request API and nothing can bypass the recording or re-target the device. +class WriterDevice final : protected modbus::ModbusClientDevice { + protected: + void on_response(std::span request_pdu, std::span response_pdu) override; + void on_error(std::span request_pdu, modbus::ExceptionCode exception_code) override; + void on_sent(std::span request_pdu) override; + void on_not_sent(std::span request_pdu) override; + bool on_no_response(std::span request_pdu) override; + + void notify_online_(std::span request_pdu); + /// Function code / register address decoded from a request PDU ([fc, addr_hi, addr_lo, ...]). + static int fc_of(std::span pdu) { return pdu.empty() ? 0 : (pdu[0] & modbus::FUNCTION_CODE_MASK); } + static int addr_of(std::span pdu) { + return pdu.size() >= 3 ? modbus::helpers::get_data(pdu.data(), 1) : 0; + } + + /// Declared before controller_ so they land in the padding after ModbusClientDevice::custom_response_warned_ + /// instead of adding a word to every entity that owns a device. + /// dispatched_: a frame was queued since the last clear_dispatched_(). + /// write_buffer_deprecated_warned_: warn-once for the legacy write_lambda buffer parameter. + bool dispatched_{false}; + bool write_buffer_deprecated_warned_{false}; + ModbusController *controller_{nullptr}; + + public: + /// Whether a frame was queued to the hub since the last clear_dispatched_(). + bool dispatched() const { return this->dispatched_; } + + bool write_single_register(uint16_t address, uint16_t value) { + this->dispatched_ = true; + return modbus::ModbusClientDevice::write_single_register(address, value); + } + bool write_single_coil(uint16_t address, bool value) { + this->dispatched_ = true; + return modbus::ModbusClientDevice::write_single_coil(address, value); + } + bool write_multiple_registers(uint16_t address, std::span values) { + this->dispatched_ = true; + return modbus::ModbusClientDevice::write_multiple_registers(address, values); + } + bool write_multiple_coils(uint16_t address, std::span values) { + this->dispatched_ = true; + return modbus::ModbusClientDevice::write_multiple_coils(address, values); + } + bool write_multiple_coils(uint16_t address, modbus::PackedBits bits) { + this->dispatched_ = true; + return modbus::ModbusClientDevice::write_multiple_coils(address, bits); + } + bool queue_pdu(std::span pdu, modbus::CommandOptions options = {}) { + this->dispatched_ = true; + return modbus::ModbusClientDevice::queue_pdu(pdu, options); + } + /// Send a legacy raw frame (address + function code + data) to the frame's own address. + /// Serves only the deprecated write_lambda buffer path. Remove before 2027.3.0. + bool send_raw_frame_deprecated(std::span frame); + + void clear_tx_queue_for_device() { modbus::ModbusClientDevice::clear_tx_queue_for_device(); } + + // Entity plumbing, public because the owning WriterEntity holds the only reachable instance (device_ is + // protected there and the hub sees just the masked base) - reachability is the access gate, not a friend. + void set_controller(ModbusController *controller); + void clear_dispatched() { this->dispatched_ = false; } + /// Warn once per entity that filling the write_lambda buffer parameter is deprecated (the entity is now the + /// command - call a write helper / queue_pdu() on `item` instead). The buffer parameter is removed in 2027.3.0. + void warn_write_buffer_deprecated(const LogString *platform, uint16_t address); +}; + +/// Gives a writer entity the write API of the WriterDevice it owns. The device is a member, not a base: +/// the mixin declares no virtual function, so an entity mixing it in gains no second vtable and all the +/// writer platforms share the single WriterDevice vtable instead of each emitting its own copy. +/// The forwarders keep `item->write_*()` working unchanged inside a write_lambda. +class WriterEntity { + public: + bool dispatched() const { return this->device_.dispatched(); } + bool write_single_register(uint16_t address, uint16_t value) { + return this->device_.write_single_register(address, value); + } + bool write_single_coil(uint16_t address, bool value) { return this->device_.write_single_coil(address, value); } + bool write_multiple_registers(uint16_t address, std::span values) { + return this->device_.write_multiple_registers(address, values); + } + bool write_multiple_coils(uint16_t address, std::span values) { + return this->device_.write_multiple_coils(address, values); + } + bool write_multiple_coils(uint16_t address, modbus::PackedBits bits) { + return this->device_.write_multiple_coils(address, bits); + } + bool queue_pdu(std::span pdu, modbus::CommandOptions options = {}) { + return this->device_.queue_pdu(pdu, options); + } + void clear_tx_queue_for_device() { this->device_.clear_tx_queue_for_device(); } + + protected: + bool send_raw_frame_deprecated_(std::span frame) { + return this->device_.send_raw_frame_deprecated(frame); + } + void set_controller_(ModbusController *controller) { this->device_.set_controller(controller); } + void clear_dispatched_() { this->device_.clear_dispatched(); } + void warn_write_buffer_deprecated_(const LogString *platform, uint16_t address) { + this->device_.warn_write_buffer_deprecated(platform, address); + } + + WriterDevice device_; +}; + /// A single modbus command. Each command is its own ModbusClientDevice: it sends its frame to the hub /// and the hub routes the response back to this object's on_modbus_* callbacks, so the controller no /// longer has to match responses to a FIFO queue. @@ -398,17 +507,16 @@ inline bool offline_retry_due(uint16_t update_counter, uint16_t module_offline_a class ModbusController final : public PollingComponent { public: + // The controller is not itself a modbus device - its commands and writer entities send as their own + // devices, built against this hub + address. + ModbusController(modbus::ModbusClientHub *hub, uint8_t address) : hub_(hub), address_(address) {} + void dump_config() override; // No loop() override: the hub owns transmit/receive timing and each command routes its own // response, so the controller never joins the looping components at all. void setup() override; void update() override; - // The controller is not itself a modbus device - its commands and writer entities send as their own - // devices. It only owns the hub + address so those senders can be built against them. - void set_parent(modbus::ModbusClientHub *hub) { this->hub_ = hub; } - void set_address(uint8_t address) { this->address_ = address; } - /// The hub and modbus address this controller talks to. Used to build commands/entities that send as /// their own device. modbus::ModbusClientHub *hub() const { return this->hub_; } diff --git a/esphome/components/modbus_controller/number/__init__.py b/esphome/components/modbus_controller/number/__init__.py index a43e10a51e..6a5b7041b8 100644 --- a/esphome/components/modbus_controller/number/__init__.py +++ b/esphome/components/modbus_controller/number/__init__.py @@ -3,6 +3,7 @@ from esphome.components import number from esphome.components.modbus.helpers import ( MODBUS_WRITE_REGISTER_TYPE, SENSOR_VALUE_TYPE, + RegisterValues, ) import esphome.config_validation as cv from esphome.const import ( @@ -13,6 +14,7 @@ from esphome.const import ( CONF_MULTIPLY, CONF_STEP, ) +from esphome.types import ConfigType from .. import ( ModbusItemBaseSchema, @@ -43,7 +45,7 @@ ModbusNumber = modbus_controller_ns.class_( ) -def validate_min_max(config): +def validate_min_max(config: ConfigType) -> ConfigType: if config[CONF_MAX_VALUE] <= config[CONF_MIN_VALUE]: raise cv.Invalid("max_value must be greater than min_value") if config[CONF_MIN_VALUE] < -16777215: @@ -53,7 +55,7 @@ def validate_min_max(config): return config -def validate_modbus_number(config): +def validate_modbus_number(config: ConfigType) -> ConfigType: # custom_command is the deprecated alias for custom_pdu (migrated later in final validate). has_custom = CONF_CUSTOM_PDU in config or CONF_CUSTOM_COMMAND in config if not has_custom and CONF_ADDRESS not in config: @@ -89,7 +91,7 @@ CONFIG_SCHEMA = cv.All( FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item -async def to_code(config): +async def to_code(config: ConfigType) -> None: byte_offset, reg_count = modbus_calc_properties(config) var = cg.new_Pvariable( config[CONF_ID], @@ -124,7 +126,7 @@ async def to_code(config): [ (ModbusNumber.operator("ptr"), "item"), (cg.float_, "x"), - (cg.std_vector.template(cg.uint16).operator("ref"), "payload"), + (RegisterValues.operator("ref"), "payload"), ], return_type=cg.optional.template(float), ) diff --git a/esphome/components/modbus_controller/number/modbus_number.cpp b/esphome/components/modbus_controller/number/modbus_number.cpp index 7903b2e317..e890a2a9ac 100644 --- a/esphome/components/modbus_controller/number/modbus_number.cpp +++ b/esphome/components/modbus_controller/number/modbus_number.cpp @@ -1,4 +1,3 @@ -#include #include "modbus_number.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -29,62 +28,73 @@ void ModbusNumber::parse_and_publish(std::span data) { } void ModbusNumber::control(float value) { - optional write_cmd; - std::vector data; + this->clear_dispatched_(); + // A new write supersedes this entity's own not-yet-sent writes: drop them (and detach any in-flight one) + // so a rapidly-changing value writes the latest, not every intermediate. + this->clear_tx_queue_for_device(); + modbus::RegisterValues data; float write_value = value; - // Is there are lambda configured? if (this->write_transform_func_.has_value()) { - // data is passed by reference - // the lambda can fill the empty vector directly - // in that case the return value is ignored + // The lambda may drive the write itself via item->write_*(), override the value (return a value), or + // (deprecated) fill `data` with the register words to write. auto val = (*this->write_transform_func_)(this, value, data); - if (val.has_value()) { - ESP_LOGV(TAG, "Value overwritten by lambda"); - write_value = val.value(); - } else { + if (this->dispatched()) { + this->publish_state(value); + return; + } + if (!data.empty()) { + // Deprecated buffer path (frozen): the lambda filled a legacy raw frame as words; pack it big-endian. + this->warn_write_buffer_deprecated_(LOG_STR("number"), this->start_address); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char hex_buf[format_hex_pretty_uint16_size(MODBUS_NUMBER_MAX_LOG_REGISTERS)]; +#endif + ESP_LOGV(TAG, "Modbus Number write raw: %s", + format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); + // Sized to hold RegisterValues at capacity, so a full buffer can never truncate into a valid frame. + StaticVector bytes; + for (uint16_t word : data) { + const auto word_bytes = decode_value(word); + bytes.push_back(word_bytes[0]); + bytes.push_back(word_bytes[1]); + } + if (!this->send_raw_frame_deprecated_(std::span(bytes.data(), bytes.size()))) { + ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", this->get_name().c_str()); + return; + } + this->publish_state(value); + return; + } + if (!val.has_value()) { ESP_LOGV(TAG, "Communication handled by lambda - exiting control"); return; } + ESP_LOGV(TAG, "Value overwritten by lambda"); + write_value = val.value(); } else { write_value = this->multiply_by_ * write_value; } - if (!data.empty()) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_uint16_size(MODBUS_NUMBER_MAX_LOG_REGISTERS)]; -#endif - ESP_LOGV(TAG, "Modbus Number write raw: %s", - format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); - write_cmd.emplace(ModbusCommandItem::create_custom_command( - this->parent_, data, - [this](modbus::EntityType register_type, uint16_t start_address, std::span data) { - this->parent_->on_write_register_response(register_type, this->start_address, data); - })); - } else { - std::vector payload; - modbus::helpers::float_to_payload(payload, write_value, this->sensor_value_type); + modbus::helpers::float_to_payload(data, write_value, this->sensor_value_type); + // float_to_payload() appends nothing for RAW, so an empty payload must be caught before data[0] below. + if (data.empty()) { + ESP_LOGW(TAG, "No payload was created for updating number"); + return; + } - ESP_LOGD(TAG, - "Updating register: connected Sensor=%s start address=0x%X register count=%d new value=%.02f (val=%.02f)", - this->get_name().c_str(), this->start_address, this->register_count, value, write_value); + ESP_LOGD(TAG, + "Updating register: connected Sensor=%s start address=0x%X register count=%d new value=%.02f (val=%.02f)", + this->get_name().c_str(), this->start_address, this->register_count, value, write_value); - // Create and send the write command - if (this->register_count == 1 && !this->use_write_multiple_) { - write_cmd.emplace( - ModbusCommandItem::create_write_single_command(this->parent_, this->write_address(), payload[0])); - } else { - write_cmd.emplace(ModbusCommandItem::create_write_multiple_command(this->parent_, this->write_address(), - this->register_count, payload)); - } - // publish new value - write_cmd->on_data_func = [this, value](modbus::EntityType register_type, uint16_t start_address, - std::span data) { - // gets called when the write command is ack'd from the device - this->parent_->on_write_register_response(register_type, start_address, data); - this->publish_state(value); - }; + bool queued; + if (this->register_count == 1 && !this->use_write_multiple_) { + queued = this->write_single_register(this->write_address(), data[0]); + } else { + queued = this->write_multiple_registers(this->write_address(), data); + } + if (!queued) { + ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", this->get_name().c_str()); + return; } - this->parent_->queue_command(std::move(*write_cmd)); this->publish_state(value); } void ModbusNumber::dump_config() { LOG_NUMBER(TAG, "Modbus Number", this); } diff --git a/esphome/components/modbus_controller/number/modbus_number.h b/esphome/components/modbus_controller/number/modbus_number.h index 538a982f80..59c76e18f2 100644 --- a/esphome/components/modbus_controller/number/modbus_number.h +++ b/esphome/components/modbus_controller/number/modbus_number.h @@ -10,7 +10,7 @@ namespace esphome::modbus_controller { using value_to_data_t = std::function(float); -class ModbusNumber final : public number::Number, public Component, public SensorItem { +class ModbusNumber final : public number::Number, public Component, public SensorItem, public WriterEntity { public: ModbusNumber(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, SensorValueType value_type, int register_count, bool force_new_range) { @@ -26,11 +26,11 @@ class ModbusNumber final : public number::Number, public Component, public Senso void dump_config() override; void parse_and_publish(std::span data) override; float get_setup_priority() const override { return setup_priority::HARDWARE; } - void set_parent(ModbusController *parent) { this->parent_ = parent; } + void set_parent(ModbusController *parent) { this->set_controller_(parent); } void set_write_multiply(float factor) { this->multiply_by_ = factor; } using transform_func_t = optional (*)(ModbusNumber *, float, std::span); - using write_transform_func_t = optional (*)(ModbusNumber *, float, std::vector &); + using write_transform_func_t = optional (*)(ModbusNumber *, float, modbus::RegisterValues &); void set_template(transform_func_t f) { this->transform_func_ = f; } void set_write_template(write_transform_func_t f) { this->write_transform_func_ = f; } void set_use_write_mutiple(bool use_write_multiple) { this->use_write_multiple_ = use_write_multiple; } @@ -39,7 +39,6 @@ class ModbusNumber final : public number::Number, public Component, public Senso void control(float value) override; optional transform_func_{nullopt}; optional write_transform_func_{nullopt}; - ModbusController *parent_{nullptr}; float multiply_by_{1.0}; bool use_write_multiple_{false}; }; diff --git a/esphome/components/modbus_controller/output/__init__.py b/esphome/components/modbus_controller/output/__init__.py index 178c99caa1..34a0f488ec 100644 --- a/esphome/components/modbus_controller/output/__init__.py +++ b/esphome/components/modbus_controller/output/__init__.py @@ -1,8 +1,13 @@ import esphome.codegen as cg from esphome.components import output -from esphome.components.modbus.helpers import SENSOR_VALUE_TYPE +from esphome.components.modbus.helpers import ( + SENSOR_VALUE_TYPE, + PduBuffer, + RegisterValues, +) import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID, CONF_MULTIPLY +from esphome.types import ConfigType from .. import ( ModbusItemBaseSchema, @@ -73,7 +78,7 @@ CONFIG_SCHEMA = cv.typed_schema( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: byte_offset, reg_count = modbus_calc_properties(config) # Binary Output write_template = None @@ -89,7 +94,7 @@ async def to_code(config): [ (ModbusBinaryOutput.operator("ptr"), "item"), (cg.bool_, "x"), - (cg.std_vector.template(cg.uint8).operator("ref"), "payload"), + (PduBuffer.operator("ref"), "payload"), ], return_type=cg.optional.template(bool), ) @@ -109,7 +114,7 @@ async def to_code(config): [ (ModbusFloatOutput.operator("ptr"), "item"), (cg.float_, "x"), - (cg.std_vector.template(cg.uint16).operator("ref"), "payload"), + (RegisterValues.operator("ref"), "payload"), ], return_type=cg.optional.template(float), ) diff --git a/esphome/components/modbus_controller/output/modbus_output.cpp b/esphome/components/modbus_controller/output/modbus_output.cpp index 48249f4387..b05d3889fd 100644 --- a/esphome/components/modbus_controller/output/modbus_output.cpp +++ b/esphome/components/modbus_controller/output/modbus_output.cpp @@ -2,6 +2,8 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include + namespace esphome::modbus_controller { static const char *const TAG = "modbus_controller.output"; @@ -13,25 +15,33 @@ static constexpr size_t MODBUS_OUTPUT_MAX_LOG_BYTES = 64; * */ void ModbusFloatOutput::write_state(float value) { - std::vector data; + this->clear_dispatched_(); + // A new write supersedes this entity's own not-yet-sent writes: drop them (and detach any in-flight one) + // so a rapidly-changing value writes the latest, not every intermediate. + this->clear_tx_queue_for_device(); + modbus::RegisterValues data; auto original_value = value; - // Is there are lambda configured? if (this->write_transform_func_.has_value()) { - // data is passed by reference - // the lambda can fill the empty vector directly - // in that case the return value is ignored + // The lambda may drive the write itself via item->write_*(), override the value (return a value), or + // (deprecated) fill `data` with the register words to write. auto val = (*this->write_transform_func_)(this, value, data); - if (val.has_value()) { - ESP_LOGV(TAG, "Value overwritten by lambda"); - value = val.value(); - } else { + if (this->dispatched()) { + return; + } + if (!data.empty()) { + // Deprecated buffer path (frozen): the lambda supplied the register words for the shared write below. + this->warn_write_buffer_deprecated_(LOG_STR("float output"), this->start_address); + } else if (!val.has_value()) { ESP_LOGV(TAG, "Communication handled by lambda - exiting control"); return; + } else { + ESP_LOGV(TAG, "Value overwritten by lambda"); + value = val.value(); } } else { value = this->multiply_by_ * value; } - // lambda didn't set payload + if (data.empty()) { modbus::helpers::float_to_payload(data, value, this->sensor_value_type); } @@ -57,16 +67,15 @@ void ModbusFloatOutput::write_state(float value) { return; } - // Create and send the write command - optional write_cmd; + bool queued; if (this->register_count == 1 && !this->use_write_multiple_) { - write_cmd.emplace( - ModbusCommandItem::create_write_single_command(this->parent_, this->start_address + this->offset, data[0])); + queued = this->write_single_register(this->write_address(), data[0]); } else { - write_cmd.emplace(ModbusCommandItem::create_write_multiple_command( - this->parent_, this->start_address + this->offset, data.size(), data)); + queued = this->write_multiple_registers(this->write_address(), data); + } + if (!queued) { + ESP_LOGW(TAG, "Modbus output write (address 0x%X) was refused by the hub", this->write_address()); } - this->parent_->queue_command(std::move(*write_cmd)); } void ModbusFloatOutput::dump_config() { @@ -81,50 +90,52 @@ void ModbusFloatOutput::dump_config() { // ModbusBinaryOutput void ModbusBinaryOutput::write_state(bool state) { - // This will be called every time the user requests a state change. - optional cmd; - std::vector data; + this->clear_dispatched_(); + // A new write supersedes this entity's own not-yet-sent writes: drop them (and detach any in-flight one) + // so a rapidly-changing value writes the latest, not every intermediate. + this->clear_tx_queue_for_device(); + modbus::helpers::PduBuffer data; - // Is there are lambda configured? if (this->write_transform_func_.has_value()) { - // data is passed by reference - // the lambda can fill the empty vector directly - // in that case the return value is ignored + // The lambda may drive the write itself via item->write_*/queue_pdu(), override the value (return a value), + // or (deprecated) fill `data` with a custom PDU. auto val = (*this->write_transform_func_)(this, state, data); - if (val.has_value()) { - ESP_LOGV(TAG, "Value overwritten by lambda"); - state = val.value(); - } else { + if (this->dispatched()) { + return; + } + if (!data.empty()) { + this->warn_write_buffer_deprecated_(LOG_STR("binary output"), this->start_address); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char hex_buf[format_hex_pretty_size(MODBUS_OUTPUT_MAX_LOG_BYTES)]; +#endif + ESP_LOGV(TAG, "Modbus binary output write raw: %s", + format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); + // The lambda filled a legacy raw frame (device address + function code + data). + if (!this->send_raw_frame_deprecated_(data)) { + ESP_LOGW(TAG, "Modbus output write (address 0x%X) was refused by the hub", this->write_address()); + } + return; + } + if (!val.has_value()) { ESP_LOGV(TAG, "Communication handled by lambda - exiting control"); return; } + ESP_LOGV(TAG, "Value overwritten by lambda"); + state = val.value(); } - if (!data.empty()) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MODBUS_OUTPUT_MAX_LOG_BYTES)]; -#endif - ESP_LOGV(TAG, "Modbus binary output write raw: %s", - format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); - cmd.emplace(ModbusCommandItem::create_custom_command( - this->parent_, data, - [this](modbus::EntityType register_type, uint16_t start_address, std::span data) { - this->parent_->on_write_register_response(register_type, this->start_address, data); - })); + ESP_LOGV(TAG, "Write new state: value is %s, type is %d address = %X, offset = %x", ONOFF(state), + (int) this->register_type, this->start_address, this->offset); + // offset for coil and discrete inputs is the coil/register number not bytes + bool queued; + if (this->use_write_multiple_) { + std::array states{state}; + queued = this->write_multiple_coils(this->write_address(), states); } else { - ESP_LOGV(TAG, "Write new state: value is %s, type is %d address = %X, offset = %x", ONOFF(state), - (int) this->register_type, this->start_address, this->offset); - - // offset for coil and discrete inputs is the coil/register number not bytes - if (this->use_write_multiple_) { - std::vector states{state}; - cmd.emplace( - ModbusCommandItem::create_write_multiple_coils(this->parent_, this->start_address + this->offset, states)); - } else { - cmd.emplace( - ModbusCommandItem::create_write_single_coil(this->parent_, this->start_address + this->offset, state)); - } + queued = this->write_single_coil(this->write_address(), state); + } + if (!queued) { + ESP_LOGW(TAG, "Modbus output write (address 0x%X) was refused by the hub", this->write_address()); } - this->parent_->queue_command(std::move(*cmd)); } void ModbusBinaryOutput::dump_config() { diff --git a/esphome/components/modbus_controller/output/modbus_output.h b/esphome/components/modbus_controller/output/modbus_output.h index e79c442aa4..b942dcea62 100644 --- a/esphome/components/modbus_controller/output/modbus_output.h +++ b/esphome/components/modbus_controller/output/modbus_output.h @@ -8,26 +8,24 @@ namespace esphome::modbus_controller { -class ModbusFloatOutput final : public output::FloatOutput, public Component, public SensorItem { +class ModbusFloatOutput final : public output::FloatOutput, public Component, public SensorItem, public WriterEntity { public: ModbusFloatOutput(uint16_t start_address, uint8_t offset, SensorValueType value_type, int register_count) { this->register_type = modbus::EntityType::HOLDING; - this->set_address(start_address); - this->set_offset_from_start_address(offset); + this->set_address(start_address + offset); + this->set_offset_from_start_address(0); this->bitmask = 0xFFFFFFFF; this->register_count = register_count; this->sensor_value_type = value_type; - this->set_address(this->start_address + offset); - this->set_offset_from_start_address(0); } void dump_config() override; - void set_parent(ModbusController *parent) { this->parent_ = parent; } + void set_parent(ModbusController *parent) { this->set_controller_(parent); } void set_write_multiply(float factor) { this->multiply_by_ = factor; } // Do nothing void parse_and_publish(std::span data) override{}; - using write_transform_func_t = optional (*)(ModbusFloatOutput *, float, std::vector &); + using write_transform_func_t = optional (*)(ModbusFloatOutput *, float, modbus::RegisterValues &); void set_write_template(write_transform_func_t f) { this->write_transform_func_ = f; } void set_use_write_mutiple(bool use_write_multiple) { this->use_write_multiple_ = use_write_multiple; } @@ -35,29 +33,28 @@ class ModbusFloatOutput final : public output::FloatOutput, public Component, pu void write_state(float value) override; optional write_transform_func_{nullopt}; - ModbusController *parent_{nullptr}; float multiply_by_{1.0}; bool use_write_multiple_{false}; }; -class ModbusBinaryOutput final : public output::BinaryOutput, public Component, public SensorItem { +class ModbusBinaryOutput final : public output::BinaryOutput, public Component, public SensorItem, public WriterEntity { public: ModbusBinaryOutput(uint16_t start_address, uint8_t offset) { this->register_type = modbus::EntityType::COIL; - this->set_address(start_address); + // A coil offset is a coil count; fold it into the address. + this->set_address(start_address + offset); this->bitmask = 0xFFFFFFFF; this->sensor_value_type = SensorValueType::BIT; this->register_count = 1; - this->set_address(this->start_address + offset); this->set_offset_from_start_address(0); } void dump_config() override; - void set_parent(ModbusController *parent) { this->parent_ = parent; } + void set_parent(ModbusController *parent) { this->set_controller_(parent); } // Do nothing void parse_and_publish(std::span data) override{}; - using write_transform_func_t = optional (*)(ModbusBinaryOutput *, bool, std::vector &); + using write_transform_func_t = optional (*)(ModbusBinaryOutput *, bool, modbus::helpers::PduBuffer &); void set_write_template(write_transform_func_t f) { this->write_transform_func_ = f; } void set_use_write_mutiple(bool use_write_multiple) { this->use_write_multiple_ = use_write_multiple; } @@ -65,7 +62,6 @@ class ModbusBinaryOutput final : public output::BinaryOutput, public Component, void write_state(bool state) override; optional write_transform_func_{nullopt}; - ModbusController *parent_{nullptr}; bool use_write_multiple_{false}; }; diff --git a/esphome/components/modbus_controller/select/__init__.py b/esphome/components/modbus_controller/select/__init__.py index 1d77f9235d..07893e3303 100644 --- a/esphome/components/modbus_controller/select/__init__.py +++ b/esphome/components/modbus_controller/select/__init__.py @@ -1,8 +1,16 @@ +from collections.abc import Callable +from typing import Any + import esphome.codegen as cg from esphome.components import select -from esphome.components.modbus.helpers import SENSOR_VALUE_TYPE, TYPE_REGISTER_MAP +from esphome.components.modbus.helpers import ( + SENSOR_VALUE_TYPE, + TYPE_REGISTER_MAP, + RegisterValues, +) import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ID, CONF_LAMBDA, CONF_OPTIMISTIC +from esphome.types import ConfigType from .. import ( ModbusController, @@ -29,8 +37,8 @@ ModbusSelect = modbus_controller_ns.class_( ) -def ensure_option_map(): - def validator(value): +def ensure_option_map() -> Callable[[Any], dict[str, int]]: + def validator(value: Any) -> dict[str, int]: cv.check_not_templatable(value) option = cv.All(cv.string_strict) mapping = cv.All(cv.int_range(-(2**63), 2**63 - 1)) @@ -47,7 +55,7 @@ def ensure_option_map(): return validator -def register_count_value_type_min(value): +def register_count_value_type_min(value: ConfigType) -> ConfigType: reg_count = value.get(CONF_REGISTER_COUNT) if reg_count is not None: value_type = value[CONF_VALUE_TYPE] @@ -87,7 +95,7 @@ CONFIG_SCHEMA = cv.All( ) -async def to_code(config): +async def to_code(config: ConfigType) -> None: value_type = config[CONF_VALUE_TYPE] reg_count = config.get(CONF_REGISTER_COUNT) if reg_count is None: @@ -132,7 +140,7 @@ async def to_code(config): (ModbusSelect.operator("const_ptr"), "item"), (cg.std_string.operator("const").operator("ref"), "x"), (cg.int64, "value"), - (cg.std_vector.template(cg.uint16).operator("ref"), "payload"), + (RegisterValues.operator("ref"), "payload"), ], return_type=cg.optional.template(cg.int64), ) diff --git a/esphome/components/modbus_controller/select/modbus_select.cpp b/esphome/components/modbus_controller/select/modbus_select.cpp index 0a9383b1b0..c1cc241d6b 100644 --- a/esphome/components/modbus_controller/select/modbus_select.cpp +++ b/esphome/components/modbus_controller/select/modbus_select.cpp @@ -46,35 +46,43 @@ void ModbusSelect::control(size_t index) { const char *option = this->option_at(index); ESP_LOGD(TAG, "Found value %lld for option '%s'", *mapval, option); - std::vector data; + this->clear_dispatched_(); + // A new write supersedes this entity's own not-yet-sent writes: drop them (and detach any in-flight one) + // so a rapidly-changing value writes the latest, not every intermediate. + this->clear_tx_queue_for_device(); + modbus::RegisterValues data; if (this->write_transform_func_.has_value()) { - // Transform func requires string parameter for backward compatibility + // The lambda may drive the write itself via item->write_*(), override the mapping value (return a value), + // or (deprecated) fill `data` with the register words to write. Transform func requires string parameter + // for backward compatibility. auto val = (*this->write_transform_func_)(this, std::string(option), *mapval, data); - if (val.has_value()) { - mapval = val; - ESP_LOGV(TAG, "write_lambda returned mapping value %lld", *mapval); - } else { + if (this->dispatched()) { + if (this->optimistic_) + this->publish_state(index); + return; + } + if (!data.empty()) { + // Deprecated buffer path (frozen): the lambda supplied the register words for the shared write below. + this->warn_write_buffer_deprecated_(LOG_STR("select"), this->start_address); + } else if (!val.has_value()) { ESP_LOGD(TAG, "Communication handled by write_lambda - exiting control"); return; + } else { + mapval = val; + ESP_LOGV(TAG, "write_lambda returned mapping value %lld", *mapval); } } if (data.empty()) { modbus::helpers::number_to_payload(data, *mapval, this->sensor_value_type); - } else { - ESP_LOGV(TAG, "Using payload from write lambda"); + // number_to_payload() appends nothing for RAW. + if (data.empty()) { + ESP_LOGW(TAG, "No payload was created for updating select"); + return; + } } - if (data.empty()) { - ESP_LOGW(TAG, "No payload was created for updating select"); - return; - } - - // The command declares register_count registers, so the payload must be exactly that many words: - // a value type narrower than the declared width is zero-padded (the config deliberately allows - // register_count larger than the value type). Anything else would put a byte count on the wire - // that disagrees with the quantity field, which conformant devices reject. // register_count declares the READ range width - it may pull neighboring registers into one poll - // so a write covers exactly the registers the value occupies: the quantity comes from the payload, // never from register_count (padding to it would zero registers the user only declared for reading). @@ -86,16 +94,17 @@ void ModbusSelect::control(size_t index) { } const uint16_t write_address = this->write_address(); - optional write_cmd; + bool queued; if ((this->register_count == 1) && (!this->use_write_multiple_)) { - write_cmd.emplace(ModbusCommandItem::create_write_single_command(this->parent_, write_address, data[0])); + queued = this->write_single_register(write_address, data[0]); } else { - write_cmd.emplace( - ModbusCommandItem::create_write_multiple_command(this->parent_, write_address, data.size(), data)); + queued = this->write_multiple_registers(write_address, data); } - this->parent_->queue_command(std::move(*write_cmd)); - + if (!queued) { + ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", this->get_name().c_str()); + return; + } if (this->optimistic_) this->publish_state(index); } diff --git a/esphome/components/modbus_controller/select/modbus_select.h b/esphome/components/modbus_controller/select/modbus_select.h index 41ebd4f658..c6ac76a45b 100644 --- a/esphome/components/modbus_controller/select/modbus_select.h +++ b/esphome/components/modbus_controller/select/modbus_select.h @@ -9,7 +9,7 @@ namespace esphome::modbus_controller { -class ModbusSelect final : public Component, public select::Select, public SensorItem { +class ModbusSelect final : public Component, public select::Select, public SensorItem, public WriterEntity { public: ModbusSelect(SensorValueType sensor_value_type, uint16_t start_address, uint8_t register_count, bool force_new_range, std::vector mapping) { @@ -26,9 +26,9 @@ class ModbusSelect final : public Component, public select::Select, public Senso using transform_func_t = optional (*)(ModbusSelect *const, int64_t, std::span); using write_transform_func_t = optional (*)(ModbusSelect *const, const std::string &, int64_t, - std::vector &); + modbus::RegisterValues &); - void set_parent(ModbusController *const parent) { this->parent_ = parent; } + void set_parent(ModbusController *const parent) { this->set_controller_(parent); } void set_use_write_mutiple(bool use_write_multiple) { this->use_write_multiple_ = use_write_multiple; } void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } void set_template(transform_func_t f) { this->transform_func_ = f; } @@ -40,7 +40,6 @@ class ModbusSelect final : public Component, public select::Select, public Senso protected: std::vector mapping_{}; - ModbusController *parent_{nullptr}; bool use_write_multiple_{false}; bool optimistic_{false}; optional transform_func_{nullopt}; diff --git a/esphome/components/modbus_controller/switch/__init__.py b/esphome/components/modbus_controller/switch/__init__.py index dedd2ceedf..c52067f941 100644 --- a/esphome/components/modbus_controller/switch/__init__.py +++ b/esphome/components/modbus_controller/switch/__init__.py @@ -1,8 +1,9 @@ import esphome.codegen as cg from esphome.components import switch -from esphome.components.modbus.helpers import MODBUS_REGISTER_TYPE +from esphome.components.modbus.helpers import MODBUS_REGISTER_TYPE, PduBuffer import esphome.config_validation as cv from esphome.const import CONF_ADDRESS, CONF_ASSUMED_STATE, CONF_ID +from esphome.types import ConfigType from .. import ( ModbusItemBaseSchema, @@ -48,7 +49,7 @@ CONFIG_SCHEMA = cv.All( FINAL_VALIDATE_SCHEMA = validate_custom_pdu_item -async def to_code(config): +async def to_code(config: ConfigType) -> None: byte_offset, _ = modbus_calc_properties(config) var = cg.new_Pvariable( config[CONF_ID], @@ -74,7 +75,7 @@ async def to_code(config): [ (ModbusSwitch.operator("ptr"), "item"), (cg.bool_, "x"), - (cg.std_vector.template(cg.uint8).operator("ref"), "payload"), + (PduBuffer.operator("ref"), "payload"), ], return_type=cg.optional.template(bool), ) diff --git a/esphome/components/modbus_controller/switch/modbus_switch.cpp b/esphome/components/modbus_controller/switch/modbus_switch.cpp index 810d904d85..c942ff1e6f 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.cpp +++ b/esphome/components/modbus_controller/switch/modbus_switch.cpp @@ -3,6 +3,8 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include + namespace esphome::modbus_controller { static const char *const TAG = "modbus_controller.switch"; @@ -58,57 +60,64 @@ void ModbusSwitch::parse_and_publish(std::span data) { } void ModbusSwitch::write_state(bool state) { - // This will be called every time the user requests a state change. - optional cmd; - std::vector data; - // Is there are lambda configured? + this->clear_dispatched_(); + // A new write supersedes this entity's own not-yet-sent writes: drop them (and detach any in-flight one) + // so a rapidly-changing value writes the latest, not every intermediate. + this->clear_tx_queue_for_device(); + modbus::helpers::PduBuffer data; if (this->write_transform_func_.has_value()) { - // data is passed by reference - // the lambda can fill the empty vector directly - // in that case the return value is ignored + // The lambda may drive the write itself via item->write_*/queue_pdu(), override the written value (return a + // value), or (deprecated) fill `data` with a custom PDU. auto val = (*this->write_transform_func_)(this, state, data); - if (val.has_value()) { - ESP_LOGV(TAG, "Value overwritten by lambda"); - state = val.value(); - } else { + if (this->dispatched()) { + this->publish_state(state); + return; + } + if (!data.empty()) { + this->warn_write_buffer_deprecated_(LOG_STR("switch"), this->start_address); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + char hex_buf[format_hex_pretty_size(MODBUS_SWITCH_MAX_LOG_BYTES)]; +#endif + ESP_LOGV(TAG, "Modbus Switch write raw: %s", + format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); + // The lambda filled a legacy raw frame (device address + function code + data). + if (!this->send_raw_frame_deprecated_(data)) { + ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", this->get_name().c_str()); + return; + } + this->publish_state(state); + return; + } + if (!val.has_value()) { ESP_LOGV(TAG, "Communication handled by lambda - exiting control"); return; } + ESP_LOGV(TAG, "Value overwritten by lambda"); + state = val.value(); } - if (!data.empty()) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - char hex_buf[format_hex_pretty_size(MODBUS_SWITCH_MAX_LOG_BYTES)]; -#endif - ESP_LOGV(TAG, "Modbus Switch write raw: %s", - format_hex_pretty_to(hex_buf, sizeof(hex_buf), data.data(), data.size())); - cmd.emplace(ModbusCommandItem::create_custom_command( - this->parent_, data, - [this](modbus::EntityType register_type, uint16_t start_address, std::span data) { - this->parent_->on_write_register_response(register_type, this->start_address, data); - })); - } else { - ESP_LOGV(TAG, "write_state '%s': new value = %s type = %d address = %X offset = %x", this->get_name().c_str(), - ONOFF(state), (int) this->register_type, this->start_address, this->offset); - if (this->register_type == modbus::EntityType::COIL) { - // offset for coil and discrete inputs is the coil/register number not bytes - if (this->use_write_multiple_) { - std::vector states{state}; - cmd.emplace(ModbusCommandItem::create_write_multiple_coils(this->parent_, this->write_address(), states)); - } else { - cmd.emplace(ModbusCommandItem::create_write_single_coil(this->parent_, this->write_address(), state)); - } + ESP_LOGV(TAG, "write_state '%s': new value = %s type = %d address = %X offset = %x", this->get_name().c_str(), + ONOFF(state), (int) this->register_type, this->start_address, this->offset); + bool queued; + if (this->register_type == EntityType::COIL) { + // offset for coil and discrete inputs is the coil/register number not bytes + if (this->use_write_multiple_) { + std::array states{state}; + queued = this->write_multiple_coils(this->write_address(), states); } else { - if (this->use_write_multiple_) { - std::vector bool_states(1, state ? (0xFFFF & this->bitmask) : 0); - cmd.emplace( - ModbusCommandItem::create_write_multiple_command(this->parent_, this->write_address(), 1, bool_states)); - } else { - cmd.emplace(ModbusCommandItem::create_write_single_command(this->parent_, this->write_address(), - state ? 0xFFFF & this->bitmask : 0u)); - } + queued = this->write_single_coil(this->write_address(), state); + } + } else { + if (this->use_write_multiple_) { + std::array states{static_cast(state ? (0xFFFF & this->bitmask) : 0)}; + queued = this->write_multiple_registers(this->write_address(), states); + } else { + queued = this->write_single_register(this->write_address(), state ? 0xFFFF & this->bitmask : 0u); } } - this->parent_->queue_command(std::move(*cmd)); + if (!queued) { + ESP_LOGW(TAG, "Modbus write for '%s' was refused by the hub; state not published", this->get_name().c_str()); + return; + } this->publish_state(state); } // ModbusSwitch end diff --git a/esphome/components/modbus_controller/switch/modbus_switch.h b/esphome/components/modbus_controller/switch/modbus_switch.h index c21a1939bc..1d3d03919f 100644 --- a/esphome/components/modbus_controller/switch/modbus_switch.h +++ b/esphome/components/modbus_controller/switch/modbus_switch.h @@ -8,7 +8,7 @@ namespace esphome::modbus_controller { -class ModbusSwitch final : public Component, public switch_::Switch, public SensorItem { +class ModbusSwitch final : public Component, public switch_::Switch, public SensorItem, public WriterEntity { public: ModbusSwitch(modbus::EntityType register_type, uint16_t start_address, uint8_t offset, uint32_t bitmask, bool force_new_range) { @@ -30,17 +30,16 @@ class ModbusSwitch final : public Component, public switch_::Switch, public Sens void set_assumed_state(bool assumed_state); void set_state(bool state) { this->state = state; } void parse_and_publish(std::span data) override; - void set_parent(ModbusController *parent) { this->parent_ = parent; } + void set_parent(ModbusController *parent) { this->set_controller_(parent); } using transform_func_t = optional (*)(ModbusSwitch *, bool, std::span); - using write_transform_func_t = optional (*)(ModbusSwitch *, bool, std::vector &); + using write_transform_func_t = optional (*)(ModbusSwitch *, bool, modbus::helpers::PduBuffer &); void set_template(transform_func_t f) { this->publish_transform_func_ = f; } void set_write_template(write_transform_func_t f) { this->write_transform_func_ = f; } void set_use_write_mutiple(bool use_write_multiple) { this->use_write_multiple_ = use_write_multiple; } protected: bool assumed_state() override; - ModbusController *parent_{nullptr}; bool use_write_multiple_{false}; optional publish_transform_func_{nullopt}; optional write_transform_func_{nullopt}; diff --git a/tests/components/modbus_controller/command_payload_test.cpp b/tests/components/modbus_controller/command_payload_test.cpp index c125a44da5..a0a59f5106 100644 --- a/tests/components/modbus_controller/command_payload_test.cpp +++ b/tests/components/modbus_controller/command_payload_test.cpp @@ -13,7 +13,7 @@ namespace esphome::modbus_controller::testing { // malformed. Built at its true byte count, the oversize frame is refused by the hub's size check with // a log instead. TEST(ModbusCommandPayload, CoilWritePayloadIsExactSizedNotTruncated) { - ModbusController controller; + ModbusController controller(nullptr, 1); std::vector coils(modbus::MAX_NUM_OF_COILS_TO_WRITE + 1, true); auto cmd = ModbusCommandItem::create_write_multiple_coils(&controller, 0x10, coils); EXPECT_EQ(cmd.payload.size(), modbus::packed_bit_bytes(coils.size())); @@ -21,7 +21,7 @@ TEST(ModbusCommandPayload, CoilWritePayloadIsExactSizedNotTruncated) { // LSB-first packing with zeroed pad bits, matching the wire layout the PDU builders produce. TEST(ModbusCommandPayload, CoilWritePacksLsbFirstWithZeroPad) { - ModbusController controller; + ModbusController controller(nullptr, 1); const std::vector coils{true, false, true, true}; auto cmd = ModbusCommandItem::create_write_multiple_coils(&controller, 0x10, coils); ASSERT_EQ(cmd.payload.size(), 1u); diff --git a/tests/integration/fixtures/uart_mock_modbus_lambda_write.yaml b/tests/integration/fixtures/uart_mock_modbus_lambda_write.yaml new file mode 100644 index 0000000000..86e17ea0d7 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_lambda_write.yaml @@ -0,0 +1,97 @@ +esphome: + name: uart-mock-modbus-lambda-write + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_server + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_controller + data: !lambda return data; + - id: virtual_uart_controller + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + +globals: + - id: reg30 + type: uint16_t + initial_value: "0" + +modbus: + - uart_id: virtual_uart_server + id: virtual_modbus_server + role: server + - uart_id: virtual_uart_controller + id: virtual_modbus_controller + role: client + turnaround_time: 10ms + +modbus_controller: + - address: 1 + modbus_id: virtual_modbus_controller + id: modbus_controller_1 + update_interval: 1s + +modbus_server: + - address: 1 + modbus_id: virtual_modbus_server + id: modbus_server_1 + registers: + - address: 0x30 + value_type: U_WORD + read_lambda: return id(reg30); + write_lambda: id(reg30) = x; return true; + +# A COIL-type switch (assumed_state, write-only) whose write_lambda ignores its own coil type and instead +# drives a HOLDING-REGISTER write on the mock server through the entity itself: `item` IS the command, so +# item->write_single_register() sends a register write from a coil entity (cross-type). Returning nothing +# (an empty optional) tells the write path the lambda already dispatched the frame - no default coil write. +switch: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "cross_switch" + register_type: coil + address: 0x00 + assumed_state: true + write_lambda: |- + item->write_single_register(0x30, x ? 1234 : 0); + return {}; + +sensor: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_30" + address: 0x30 + register_type: holding + value_type: U_WORD + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index 707637cfc2..3dfeda9b37 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -969,10 +969,10 @@ async def test_uart_mock_modbus_client_read_write( @pytest.mark.xfail( strict=True, - reason="Byte-accurate register-offset writes require the modbus_controller " - "entity-device change; on dev the byte offset is folded into the address " - "(writes 0x12 instead of 0x11). The write and read assertions both flip via " - "the same switch-constructor fold. Remove this marker when that change merges.", + reason="Byte-accurate register-offset writes land in the follow-up offset fix; " + "until then the byte offset is folded into the address (writes 0x12 instead of " + "0x11). The write and read assertions both flip via the same switch-constructor " + "fold. Remove this marker when that change merges.", ) @pytest.mark.asyncio async def test_uart_mock_modbus_register_offset( @@ -1029,14 +1029,42 @@ async def test_uart_mock_modbus_register_offset( ) -@pytest.mark.xfail( - strict=True, - reason="The deprecated write buffer requires the modbus_controller " - "entity-device change; on dev a nullopt-returning write_lambda early-returns " - "before the buffer is used, so the write never happens. The warn-once " - "assertion matches the log substring 'write_lambda buffer'. Remove this " - "marker when that change merges.", -) +@pytest.mark.asyncio +async def test_uart_mock_modbus_lambda_write( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test a write_lambda that drives the write through the entity itself (item is the command). + + `cross_switch` is a coil-type switch whose write_lambda ignores its own type and calls + item->write_single_register(0x30, ...) - a register write issued from a coil entity. The lambda + returns an empty optional, so the write path detects the lambda already dispatched a frame and does + not fall back to the default coil write. Success is reg_30 reading back the value the lambda wrote, + which proves both the new item->write_* path and cross-type flexibility. + """ + + tracker = SensorTracker(["reg_30"]) + initial = tracker.expect("reg_30", 0) + wrote_30 = tracker.expect("reg_30", 1234) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + entities = await tracker.setup_and_start_scenario(client) + await tracker.await_change(initial, "reg_30", timeout=4.0) + + switch = find_entity(entities, "cross_switch", SwitchInfo) + assert switch is not None, "cross_switch not found" + client.switch_command(switch.key, True) + + # The coil switch's lambda wrote register 0x30 via item->write_single_register(); reg_30 must + # read back 1234. If the entity-as-command dispatch were broken, no register write would go out + # and this would time out. + await tracker.await_change(wrote_30, "reg_30", timeout=4.0) + + @pytest.mark.asyncio async def test_uart_mock_modbus_deprecated_write_buffer( yaml_config: str, @@ -1046,9 +1074,9 @@ async def test_uart_mock_modbus_deprecated_write_buffer( """Test the deprecated write_lambda buffer path still works, and warns once per entity. buf_number's write_lambda fills the old `payload` buffer with a legacy raw frame as words (device - address + function code + data) instead of calling item->write_*. Two writes must both land with the - legacy raw-frame semantics, and the one-time deprecation warning must fire exactly once per entity - regardless of how many writes happen. + address + function code + data) and returns {} instead of calling item->write_*. Both writes must + land - a filled buffer is sent, as the docs have always described - and the one-time deprecation + warning must fire exactly once per entity regardless of how many writes happen. """ warn_count = 0 From 3361d031de3f47741503ecfcd49202f2027c0e13 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 26 Aug 2026 19:46:13 -0500 Subject: [PATCH 29/30] [api] Drop connection instead of crashing when overflow buffer allocation fails (#18802) --- esphome/components/api/api_frame_helper.cpp | 2 +- esphome/components/api/api_overflow_buffer.cpp | 16 ++++++++++++++-- esphome/components/api/api_overflow_buffer.h | 2 +- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index 7425304766..38da444a18 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -172,7 +172,7 @@ APIError APIFrameHelper::write_raw_iov_(const struct iovec *iov, int iovcnt, uin // Queue unsent data into overflow buffer if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, static_cast(sent))) { - HELPER_LOG("Overflow buffer full, dropping connection"); + HELPER_LOG("Overflow buffer full or out of memory, dropping connection"); this->state_ = State::FAILED; return APIError::SOCKET_WRITE_FAILED; } diff --git a/esphome/components/api/api_overflow_buffer.cpp b/esphome/components/api/api_overflow_buffer.cpp index a57a2fb1bb..48d8fe18ba 100644 --- a/esphome/components/api/api_overflow_buffer.cpp +++ b/esphome/components/api/api_overflow_buffer.cpp @@ -1,6 +1,7 @@ #include "api_overflow_buffer.h" #ifdef USE_API #include +#include namespace esphome::api { @@ -61,9 +62,18 @@ bool APIOverflowBuffer::enqueue_iov(const struct iovec *iov, int iovcnt, uint16_ return false; uint16_t buffer_size = total_len - skip; + // nothrow: a failed allocation returns nullptr so the connection is dropped + // cleanly instead of plain new's crash or abort on OOM // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) - auto *entry = new Entry{new uint8_t[buffer_size], buffer_size, 0}; - this->queue_[this->tail_] = entry; + auto *data = new (std::nothrow) uint8_t[buffer_size]; + if (data == nullptr) + return false; + // NOLINTNEXTLINE(cppcoreguidelines-owning-memory) + auto *entry = new (std::nothrow) Entry{data, buffer_size, 0}; + if (entry == nullptr) { + delete[] data; + return false; + } uint16_t to_skip = skip; uint16_t write_pos = 0; @@ -80,6 +90,8 @@ bool APIOverflowBuffer::enqueue_iov(const struct iovec *iov, int iovcnt, uint16_ } } + // Publish only after the copy completes so a half-built entry is never reachable + this->queue_[this->tail_] = entry; this->tail_ = (this->tail_ + 1) % API_MAX_SEND_QUEUE; this->count_++; return true; diff --git a/esphome/components/api/api_overflow_buffer.h b/esphome/components/api/api_overflow_buffer.h index 1227e83126..03a334b281 100644 --- a/esphome/components/api/api_overflow_buffer.h +++ b/esphome/components/api/api_overflow_buffer.h @@ -61,7 +61,7 @@ class APIOverflowBuffer { /// Enqueue unsent IOV data into the backlog. /// Copies iov data starting at byte offset `skip` into a new entry. - /// Returns false if the queue is full (caller should fail the connection). + /// Returns false if the queue is full or allocation fails (caller should fail the connection). bool enqueue_iov(const struct iovec *iov, int iovcnt, uint16_t total_len, uint16_t skip); protected: From 39177402ddabe5b474894a8f53860dea6a861f4f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 26 Aug 2026 19:46:28 -0500 Subject: [PATCH 30/30] [api] Deprecate media player supports_pause field (#18801) --- esphome/components/api/api.proto | 3 ++- esphome/components/api/api_connection.cpp | 1 - esphome/components/api/api_pb2.cpp | 2 -- esphome/components/api/api_pb2.h | 3 +-- esphome/components/api/api_pb2_dump.cpp | 1 - 5 files changed, 3 insertions(+), 7 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 1942ff568b..c11700782e 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -1654,7 +1654,8 @@ message ListEntitiesMediaPlayerResponse { bool disabled_by_default = 6; EntityCategory entity_category = 7; - bool supports_pause = 8; + // Deprecated in ESPHome 2026.9.0; use feature_flags instead. + bool supports_pause = 8 [deprecated = true]; repeated MediaPlayerSupportedFormat supported_formats = 9; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 9b1026d2a9..7b0cb7069e 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1099,7 +1099,6 @@ uint16_t APIConnection::try_send_media_player_info(EntityBase *entity, APIConnec auto *media_player = static_cast(entity); ListEntitiesMediaPlayerResponse msg; auto traits = media_player->get_traits(); - msg.supports_pause = traits.get_supports_pause(); msg.feature_flags = traits.get_feature_flags(); for (auto &supported_format : traits.get_supported_formats()) { msg.supported_formats.emplace_back(); diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index b5062f9e9f..f56d791b67 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -2323,7 +2323,6 @@ uint8_t *ListEntitiesMediaPlayerResponse::encode(ProtoWriteBuffer &buffer PROTO_ #endif ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default); ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category)); - ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 8, this->supports_pause); for (auto &it : this->supported_formats) { ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 9, it); } @@ -2343,7 +2342,6 @@ uint32_t ListEntitiesMediaPlayerResponse::calculate_size() const { #endif size += ProtoSize::calc_bool(1, this->disabled_by_default); size += this->entity_category ? 2 : 0; - size += ProtoSize::calc_bool(1, this->supports_pause); if (!this->supported_formats.empty()) { for (const auto &it : this->supported_formats) { size += ProtoSize::calc_message_force(1, it.calculate_size()); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 48e277fce1..bed28d2956 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1911,11 +1911,10 @@ class MediaPlayerSupportedFormat final : public ProtoMessage { class ListEntitiesMediaPlayerResponse final : public InfoResponseProtoMessage { public: static constexpr uint16_t MESSAGE_TYPE = 63; - static constexpr uint8_t ESTIMATED_SIZE = 80; + static constexpr uint8_t ESTIMATED_SIZE = 78; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("list_entities_media_player_response"); } #endif - bool supports_pause{false}; std::vector supported_formats{}; uint32_t feature_flags{0}; uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 9f53438531..846c0ad652 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1962,7 +1962,6 @@ const char *ListEntitiesMediaPlayerResponse::dump_to(DumpBuffer &out) const { #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)); - dump_field(out, ESPHOME_PSTR("supports_pause"), this->supports_pause); for (const auto &it : this->supported_formats) { out.append(4, ' ').append_p(ESPHOME_PSTR("supported_formats")).append(": "); it.dump_to(out);