From c56bb140379962d69d6f02e8a683a64bdfef40a5 Mon Sep 17 00:00:00 2001 From: AndreKR Date: Wed, 14 Oct 2026 22:39:55 +0200 Subject: [PATCH 1/9] [mqtt] Print crash info over MQTT (#17935) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/api/api_connection.cpp | 12 +++ esphome/components/api/api_connection.h | 25 +---- esphome/components/esp32/__init__.py | 1 + esphome/components/esp32/crash_handler.cpp | 5 +- esphome/components/esp32/crash_handler.h | 3 + esphome/components/esp8266/__init__.py | 1 + esphome/components/logger/logger.cpp | 2 + esphome/components/logger/logger.h | 10 ++ esphome/components/logger/logger_esp32.cpp | 11 ++ esphome/components/logger/logger_esp8266.cpp | 5 + esphome/components/logger/logger_rp2.cpp | 5 + esphome/components/mqtt/mqtt_client.cpp | 50 +++++++-- esphome/components/mqtt/mqtt_client.h | 11 ++ esphome/components/rp2/__init__.py | 1 + esphome/core/defines.h | 1 + esphome/mqtt.py | 38 +++++-- tests/unit_tests/test_mqtt.py | 107 ++++++++++++++++++- 17 files changed, 246 insertions(+), 42 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index fd51aae1be8..ca2397b5d78 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1,4 +1,7 @@ #include "api_connection.h" +#if defined(USE_LOGGER) && defined(USE_CRASH_HANDLER) +#include "esphome/components/logger/logger.h" +#endif #ifdef USE_API #include "api_connection_buffer.h" // for the APIServer-dependent APIConnection inlines #ifdef USE_API_NOISE @@ -451,6 +454,15 @@ bool APIConnection::send_disconnect_response_() { DisconnectResponse resp; return this->send_message(resp); } +void APIConnection::on_subscribe_logs_request(const SubscribeLogsRequest &msg) { + this->flags_.log_subscription = msg.level; + if (msg.dump_config) + App.schedule_dump_config(); +#if defined(USE_LOGGER) && defined(USE_CRASH_HANDLER) + logger::log_crash_to_client(); +#endif +} + void APIConnection::on_disconnect_response() { // Don't close socket here, let APIServer::loop() do it // so getpeername() still works for the disconnect trigger diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 9f784185b83..863e378a5e5 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -15,15 +15,6 @@ #include "subscribe_state.h" #include "esphome/core/application.h" #include "esphome/core/component.h" -#ifdef USE_ESP32_CRASH_HANDLER -#include "esphome/components/esp32/crash_handler.h" -#endif -#ifdef USE_RP2_CRASH_HANDLER -#include "esphome/components/rp2/crash_handler.h" -#endif -#ifdef USE_ESP8266_CRASH_HANDLER -#include "esphome/components/esp8266/crash_handler.h" -#endif #include "esphome/core/entity_base.h" #include "esphome/core/log.h" #include "esphome/core/string_ref.h" @@ -298,21 +289,7 @@ class APIConnection final : public APIServerConnectionBase { this->begin_iterator_(ActiveIterator::INITIAL_STATE); } } - void on_subscribe_logs_request(const SubscribeLogsRequest &msg) { - this->flags_.log_subscription = msg.level; - if (msg.dump_config) - App.schedule_dump_config(); -#ifdef USE_ESP32_CRASH_HANDLER - esp32::crash_handler_log(); - esp32::crash_handler_clear(); -#endif -#ifdef USE_RP2_CRASH_HANDLER - rp2::crash_handler_log(); -#endif -#ifdef USE_ESP8266_CRASH_HANDLER - esp8266::crash_handler_log(); -#endif - } + void on_subscribe_logs_request(const SubscribeLogsRequest &msg); #ifdef USE_API_HOMEASSISTANT_SERVICES void on_subscribe_homeassistant_services_request() { this->flags_.service_call_subscription = true; } #endif diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 422413a269f..25a00b522c0 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -2920,6 +2920,7 @@ async def _finalize_arduino_aware_flags(): if not arduino_linked: cg.add_build_flag("-Wl,--wrap=esp_panic_handler") cg.add_define("USE_ESP32_CRASH_HANDLER") + cg.add_define("USE_CRASH_HANDLER") elif not CORE.using_arduino: cg.add_build_flag("-DUSE_ARDUINO") diff --git a/esphome/components/esp32/crash_handler.cpp b/esphome/components/esp32/crash_handler.cpp index 182bc5f2d6c..bc28dde8d63 100644 --- a/esphome/components/esp32/crash_handler.cpp +++ b/esphome/components/esp32/crash_handler.cpp @@ -161,7 +161,7 @@ static bool s_crash_data_valid = false; // NOLINT(cppcoreguidelines-avoid-non-c namespace esphome::esp32 { -ESPHOME_LOG_TAG(TAG, "esp32.crash"); +ESPHOME_LOG_TAG(TAG, CRASH_LOG_TAG); // RAM copy of the build timestamp. The generated constant lives in flash, // which the panic handler must not read (cache may be disabled during @@ -175,7 +175,8 @@ static uint32_t s_current_build_time = static_cast(ESPHOME_BUILD_TIME) // Validate the NOINIT record. Runs on every has_data() call; re-running is // harmless and the magic is left alone so the record survives an OTA -// rollback reboot, crash_handler_clear() drops it once an API client has it. +// rollback reboot, crash_handler_clear() drops it once a log client has it +// (logger::log_crash_to_client(), for the API and MQTT). static void read_crash_data() { if (s_raw_crash_data.magic == CRASH_MAGIC && s_raw_crash_data.version == CRASH_DATA_VERSION) { s_crash_data_valid = true; diff --git a/esphome/components/esp32/crash_handler.h b/esphome/components/esp32/crash_handler.h index 314be80314c..e145a4a6032 100644 --- a/esphome/components/esp32/crash_handler.h +++ b/esphome/components/esp32/crash_handler.h @@ -4,6 +4,9 @@ namespace esphome::esp32 { +/// The tag the crash record is logged under, so a client can check the level in force for it +inline constexpr const char *CRASH_LOG_TAG = "esp32.crash"; + /// Log crash data if a crash was detected on previous boot. void crash_handler_log(); diff --git a/esphome/components/esp8266/__init__.py b/esphome/components/esp8266/__init__.py index 27c6fb1958d..09ced18fd67 100644 --- a/esphome/components/esp8266/__init__.py +++ b/esphome/components/esp8266/__init__.py @@ -420,6 +420,7 @@ async def to_code(config: ConfigType) -> None: cg.add_define("ESPHOME_VARIANT", "ESP8266") cg.add_define(ThreadModel.SINGLE) cg.add_define("USE_ESP8266_CRASH_HANDLER") + cg.add_define("USE_CRASH_HANDLER") enable_scanf_float = config.get(CONF_ENABLE_SCANF_FLOAT) if enable_scanf_float is None and lambdas_use_scanf_float(CORE.config): diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index 24a6586ae2f..ac7bfef48af 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -142,6 +142,8 @@ void Logger::log_vprintf_(uint8_t level, const char *tag, int line, const __Flas } #endif // USE_ESP8266 +uint8_t Logger::get_log_level(const char *tag) { return this->level_for(tag); } + inline uint8_t Logger::level_for(const char *tag) { #ifdef USE_LOGGER_RUNTIME_TAG_LEVELS // Skip the lookup when no per-tag levels are set diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index b82aaa7c219..475ec93e81f 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -178,6 +178,8 @@ class Logger final : public Component { void set_log_level(const char *tag, uint8_t log_level); #endif uint8_t get_log_level() { return this->current_level_; } + /// The level in force for a tag: its own when one is set, else the default + uint8_t get_log_level(const char *tag); // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) @@ -511,6 +513,14 @@ class LoggerMessageTrigger final : public Triggerget_log_level(esp32::CRASH_LOG_TAG) < ESPHOME_LOG_LEVEL_ERROR) + return; + esp32::crash_handler_log(); + esp32::crash_handler_clear(); +} +#endif + } // namespace esphome::logger #endif diff --git a/esphome/components/logger/logger_esp8266.cpp b/esphome/components/logger/logger_esp8266.cpp index 6822e8f590f..418ab3d95d9 100644 --- a/esphome/components/logger/logger_esp8266.cpp +++ b/esphome/components/logger/logger_esp8266.cpp @@ -48,6 +48,11 @@ const LogString *Logger::get_uart_selection_() { #endif } +#ifdef USE_CRASH_HANDLER +bool crash_log_pending() { return esp8266::crash_handler_has_data(); } +void log_crash_to_client() { esp8266::crash_handler_log(); } +#endif + } // namespace esphome::logger #if !defined(USE_ESP8266_LOGGER_SERIAL) && !defined(USE_ESP8266_LOGGER_SERIAL1) diff --git a/esphome/components/logger/logger_rp2.cpp b/esphome/components/logger/logger_rp2.cpp index 518a1d7b1d2..6d190df5cdb 100644 --- a/esphome/components/logger/logger_rp2.cpp +++ b/esphome/components/logger/logger_rp2.cpp @@ -49,5 +49,10 @@ const LogString *Logger::get_uart_selection_() { } } +#ifdef USE_CRASH_HANDLER +bool crash_log_pending() { return rp2::crash_handler_has_data(); } +void log_crash_to_client() { rp2::crash_handler_log(); } +#endif + } // namespace esphome::logger #endif // USE_RP2 diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index 803f486333a..297a94d2a97 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -49,6 +49,13 @@ MQTTClientComponent::MQTTClientComponent() { } // Connection +// Room for "esphome//" with the longest kind, "discover" +static constexpr size_t ESPHOME_TOPIC_BUFFER_SIZE = sizeof("esphome/discover/") + ESPHOME_DEVICE_NAME_MAX_LEN; + +static void format_esphome_topic(char (&topic)[ESPHOME_TOPIC_BUFFER_SIZE], const char *kind) { + buf_append_printf(topic, sizeof(topic), 0, "esphome/%s/%s", kind, App.get_name().c_str()); +} + void MQTTClientComponent::setup() { this->mqtt_backend_.set_on_message( [this](const char *topic, const char *payload, size_t len, size_t index, size_t total) { @@ -78,6 +85,13 @@ void MQTTClientComponent::setup() { this, [](void *self, uint8_t level, const char *tag, const char *message, size_t message_len) { static_cast(self)->on_log(level, tag, message, message_len); }); +#ifdef USE_MQTT_CRASH_LOG + // `esphome logs` announces itself here, like an API client's log subscription, and the record stays + // loggable until the next boot, so the offer does too. A log topic that drops errors would swallow the + // replay, so it is not offered. The topic is rebuilt from the device name when needed, so nothing is + // stored for it. + this->crash_log_offered_ = logger::crash_log_pending() && this->log_level_ >= ESPHOME_LOG_LEVEL_ERROR; +#endif } #endif @@ -87,10 +101,8 @@ void MQTTClientComponent::setup() { 2); // Format topic on stack - subscribe() copies it - // "esphome/ping/" (13) + name (ESPHOME_DEVICE_NAME_MAX_LEN) + null (1) - constexpr size_t ping_topic_buffer_size = 13 + ESPHOME_DEVICE_NAME_MAX_LEN + 1; - char ping_topic[ping_topic_buffer_size]; - buf_append_printf(ping_topic, sizeof(ping_topic), 0, "esphome/ping/%s", App.get_name().c_str()); + char ping_topic[ESPHOME_TOPIC_BUFFER_SIZE]; + format_esphome_topic(ping_topic, "ping"); this->subscribe( ping_topic, [this](const std::string &topic, const std::string &payload) { this->send_device_info_(); }, 2); } @@ -105,10 +117,8 @@ void MQTTClientComponent::send_device_info_() { return; } // Format topic on stack to avoid heap allocation - // "esphome/discover/" (17) + name (ESPHOME_DEVICE_NAME_MAX_LEN) + null (1) - constexpr size_t topic_buffer_size = 17 + ESPHOME_DEVICE_NAME_MAX_LEN + 1; - char topic[topic_buffer_size]; - buf_append_printf(topic, sizeof(topic), 0, "esphome/discover/%s", App.get_name().c_str()); + char topic[ESPHOME_TOPIC_BUFFER_SIZE]; + format_esphome_topic(topic, "discover"); // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson this->publish_json( @@ -354,6 +364,14 @@ void MQTTClientComponent::check_connected() { delay(100); // NOLINT this->resubscribe_subscriptions_(); +#ifdef USE_MQTT_CRASH_LOG + // Best effort: a refused subscription is tried again at the next connection + if (this->crash_log_offered_) { + char topic[ESPHOME_TOPIC_BUFFER_SIZE]; + format_esphome_topic(topic, "logs"); + this->subscribe_(topic, 0); + } +#endif this->send_device_info_(); for (MQTTComponent *component : this->children_) @@ -461,6 +479,19 @@ void MQTTClientComponent::resubscribe_subscriptions_() { } } +#ifdef USE_MQTT_CRASH_LOG +void MQTTClientComponent::handle_crash_log_request_(const std::string &topic, const std::string &payload) { + // Only the CLI's empty request counts: a log_topic pointing at this topic would otherwise replay the crash + // log for every line it logs + if (!this->crash_log_offered_ || !payload.empty()) + return; + char logs_topic[ESPHOME_TOPIC_BUFFER_SIZE]; + format_esphome_topic(logs_topic, "logs"); + if (topic == logs_topic) + logger::log_crash_to_client(); +} +#endif + void MQTTClientComponent::add_subscription_(std::string &&topic, mqtt_subscription_callback_t callback, bool boxed, uint8_t qos) { auto &subscription = this->subscriptions_.emplace_back(MQTTSubscription{ @@ -658,6 +689,9 @@ void MQTTClientComponent::on_message(const std::string &topic, const std::string // DO NOT REMOVE THIS DEFER without understanding the above. It may appear to work // in simple tests but will cause crashes with complex automations. this->defer([this, topic, payload]() { +#endif +#ifdef USE_MQTT_CRASH_LOG + this->handle_crash_log_request_(topic, payload); #endif // Indexed with the count taken up front: a callback may subscribe, which can reallocate the // vector, and the new entry waits for the next message. diff --git a/esphome/components/mqtt/mqtt_client.h b/esphome/components/mqtt/mqtt_client.h index 30edb812975..1de100019e3 100644 --- a/esphome/components/mqtt/mqtt_client.h +++ b/esphome/components/mqtt/mqtt_client.h @@ -24,6 +24,11 @@ #include +// The crash log replay needs the logger, which mqtt does not depend on +#if defined(USE_LOGGER) && defined(USE_CRASH_HANDLER) +#define USE_MQTT_CRASH_LOG +#endif + namespace esphome::mqtt { /** Callback for MQTT events. @@ -316,6 +321,9 @@ class MQTTClientComponent final : public Component { callback(topic, doc.as()); } void erase_removed_subscriptions_(); +#ifdef USE_MQTT_CRASH_LOG + void handle_crash_log_request_(const std::string &topic, const std::string &payload); +#endif void add_subscription_(std::string &&topic, mqtt_subscription_callback_t callback, bool boxed, uint8_t qos); void resubscribe_subscriptions_(); @@ -359,6 +367,9 @@ class MQTTClientComponent final : public Component { bool dns_resolved_{false}; bool dns_resolve_error_{false}; bool enable_on_boot_{true}; +#ifdef USE_MQTT_CRASH_LOG + bool crash_log_offered_{false}; +#endif std::vector children_; uint32_t reboot_timeout_{300000}; uint32_t connect_begin_; diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index d46858d3977..1c74c8d5ac3 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -392,6 +392,7 @@ async def to_code(config: ConfigType) -> None: "USE_RP2040_WATCHDOG_TIMEOUT", config[CONF_WATCHDOG_TIMEOUT] ) # back-compat alias cg.add_define("USE_RP2_CRASH_HANDLER") + cg.add_define("USE_CRASH_HANDLER") cg.add_define("USE_RP2040_CRASH_HANDLER") # back-compat alias _configure_lwip() diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 6075fc93797..3d14494df7a 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -58,6 +58,7 @@ #define USE_CLIMATE_VISUAL_OVERRIDES #define USE_CONTROLLER_REGISTRY #define USE_COVER +#define USE_CRASH_HANDLER #define USE_DATETIME #define USE_DATETIME_DATE #define USE_DATETIME_DATETIME diff --git a/esphome/mqtt.py b/esphome/mqtt.py index 62deafb09a2..6f0426e4f49 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING import paho.mqtt.client as mqtt from esphome.const import ( + CONF_BIRTH_MESSAGE, CONF_BROKER, CONF_CERTIFICATE_AUTHORITY, CONF_CLIENT_CERTIFICATE, @@ -21,7 +22,9 @@ from esphome.const import ( CONF_MQTT, CONF_NAME, CONF_PASSWORD, + CONF_PAYLOAD, CONF_PORT, + CONF_RETAIN, CONF_SKIP_CERT_CN_CHECK, CONF_TOPIC, CONF_TOPIC_PREFIX, @@ -319,13 +322,36 @@ def show_logs(config, topic=None, username=None, password=None, client_id=None): return 1 _LOGGER.info("Starting log output from %s", topic) - def on_message(client, userdata, msg): - time_ = datetime.now().astimezone().time().strftime("[%H:%M:%S]") - payload = msg.payload.decode(errors="backslashreplace") - message = time_ + payload - safe_print(message) + logs_topic = f"esphome/logs/{config[CONF_ESPHOME][CONF_NAME]}" + # Ask when the retained birth message reports online: a request sent while the device is down is + # lost, and only a retained message tells a CLI that connects later that the device is already up. + # An empty payload deletes the retained message instead, so it counts as no birth message. + birth = config.get(CONF_MQTT, {}).get(CONF_BIRTH_MESSAGE) or {} + birth_payload = birth.get(CONF_PAYLOAD) + birth_topic = birth.get(CONF_TOPIC) if birth_payload else None + ask_on_connect = birth_topic is None or not birth[CONF_RETAIN] - return initialize(config, [topic], on_message, None, username, password, client_id) + def request_crash_log(client): + _LOGGER.debug("Requesting the crash log via topic: %s", logs_topic) + client.publish(logs_topic, None, retain=False) + + def on_message(client, userdata, msg): + payload = msg.payload.decode(errors="backslashreplace") + if msg.topic == birth_topic and payload == birth_payload: + request_crash_log(client) + # The birth topic may sit inside the log subscription, so print by what was asked for + if mqtt.topic_matches_sub(topic, msg.topic): + time_ = datetime.now().astimezone().time().strftime("[%H:%M:%S]") + safe_print(time_ + payload) + + def on_connect(client, userdata, flags, return_code): + if ask_on_connect: + request_crash_log(client) + + subscriptions = [topic] if birth_topic is None else [topic, birth_topic] + return initialize( + config, subscriptions, on_message, on_connect, username, password, client_id + ) def clear_topic(config, topic, username=None, password=None, client_id=None): diff --git a/tests/unit_tests/test_mqtt.py b/tests/unit_tests/test_mqtt.py index 1ae10d0eb54..ad45db88ca2 100644 --- a/tests/unit_tests/test_mqtt.py +++ b/tests/unit_tests/test_mqtt.py @@ -9,9 +9,112 @@ from unittest.mock import MagicMock, patch import pytest -from esphome.const import CONF_BROKER, CONF_ESPHOME, CONF_MQTT, CONF_NAME +from esphome.const import ( + CONF_BIRTH_MESSAGE, + CONF_BROKER, + CONF_ESPHOME, + CONF_MQTT, + CONF_NAME, + CONF_PAYLOAD, + CONF_RETAIN, + CONF_TOPIC, +) from esphome.core import EsphomeError -from esphome.mqtt import get_esphome_device_ip +from esphome.mqtt import get_esphome_device_ip, show_logs + + +@pytest.mark.parametrize( + "birth", + [ + None, + {CONF_TOPIC: "test-device/status", CONF_PAYLOAD: "online", CONF_RETAIN: False}, + {CONF_TOPIC: "test-device/status", CONF_PAYLOAD: "", CONF_RETAIN: True}, + ], + ids=["no birth message", "unretained birth message", "empty birth payload"], +) +@patch("esphome.mqtt.initialize") +def test_show_logs_asks_on_connect( + mock_initialize: MagicMock, birth: dict | None +) -> None: + """Without a retained birth message nothing tells a later CLI the device is up, so it asks on connect.""" + config = _discovery_config() + if birth is not None: + config[CONF_MQTT][CONF_BIRTH_MESSAGE] = birth + + show_logs(config) + + on_connect = mock_initialize.call_args.args[3] + client = MagicMock() + on_connect(client, None, None, 0) + client.publish.assert_called_once_with( + "esphome/logs/test-device", None, retain=False + ) + if birth is None or not birth[CONF_PAYLOAD]: + assert mock_initialize.call_args.args[1] == ["test-device/debug"] + + +@patch("esphome.mqtt.safe_print") +@patch("esphome.mqtt.initialize") +def test_show_logs_requests_crash_log_when_device_reports_online( + mock_initialize: MagicMock, mock_print: MagicMock +) -> None: + """With a retained birth message the request follows the device coming online, not the CLI connecting.""" + config = _discovery_config() + config[CONF_MQTT][CONF_BIRTH_MESSAGE] = { + CONF_TOPIC: "test-device/status", + CONF_PAYLOAD: "online", + CONF_RETAIN: True, + } + + show_logs(config) + + assert mock_initialize.call_args.args[1] == [ + "test-device/debug", + "test-device/status", + ] + on_message = mock_initialize.call_args.args[2] + on_connect = mock_initialize.call_args.args[3] + client = MagicMock() + on_connect(client, None, None, 0) + client.publish.assert_not_called() + + on_message(client, None, MagicMock(topic="test-device/status", payload=b"offline")) + client.publish.assert_not_called() + on_message(client, None, MagicMock(topic="test-device/status", payload=b"online")) + client.publish.assert_called_once_with( + "esphome/logs/test-device", None, retain=False + ) + mock_print.assert_not_called() + + on_message( + client, None, MagicMock(topic="test-device/debug", payload=b"[I][app]: hi") + ) + assert mock_print.call_args.args[0].endswith("[I][app]: hi") + client.publish.assert_called_once() + + +@patch("esphome.mqtt.safe_print") +@patch("esphome.mqtt.initialize") +def test_show_logs_prints_a_birth_message_inside_the_log_subscription( + mock_initialize: MagicMock, mock_print: MagicMock +) -> None: + """A birth topic covered by the log topic is still printed, and still requests the crash log.""" + config = _discovery_config() + config[CONF_MQTT][CONF_BIRTH_MESSAGE] = { + CONF_TOPIC: "test-device/status", + CONF_PAYLOAD: "online", + CONF_RETAIN: True, + } + + show_logs(config, topic="test-device/#") + + on_message = mock_initialize.call_args.args[2] + client = MagicMock() + on_message(client, None, MagicMock(topic="test-device/status", payload=b"online")) + client.publish.assert_called_once_with( + "esphome/logs/test-device", None, retain=False + ) + assert mock_print.call_args.args[0].endswith("online") def test_get_esphome_device_ip_empty_broker() -> None: From ac74796263f8e5d4b1f6b657f2128c3599511440 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 10:52:13 -1000 Subject: [PATCH 2/9] [friedrich] Clear saved preferences in the test fixture (#20770) --- tests/components/friedrich/friedrich_test.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/components/friedrich/friedrich_test.cpp b/tests/components/friedrich/friedrich_test.cpp index 610eca9c091..baf4e53652e 100644 --- a/tests/components/friedrich/friedrich_test.cpp +++ b/tests/components/friedrich/friedrich_test.cpp @@ -4,6 +4,7 @@ #include "esphome/components/friedrich/friedrich.h" #include "esphome/components/remote_base/aeha_protocol.h" #include "esphome/core/helpers.h" +#include "esphome/core/preferences.h" namespace esphome::friedrich::testing { @@ -41,7 +42,12 @@ struct Fixture { TestFriedrich sut; CapturingTransmitter transmitter; - Fixture() { this->sut.set_transmitter(&this->transmitter); } + Fixture() { + // Climate tests share one preference key, so clear what an earlier test saved or setup restores it + if (global_preferences != nullptr) + global_preferences->reset(); + this->sut.set_transmitter(&this->transmitter); + } // The last transmission decoded, or an empty frame if it could not be decoded AEHAData sent() { return AEHAProtocol().decode(rx_of(this->transmitter.last)).value_or(AEHAData{}); } From d3668a71866fbf52e85010aa6403a385d42c20f9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 11:13:32 -1000 Subject: [PATCH 3/9] [mqtt] Match subscription topics in a loop instead of recursing per character (#20767) --- esphome/components/mqtt/mqtt_client.cpp | 58 +----------- esphome/components/mqtt/mqtt_topic_match.h | 28 ++++++ tests/components/mqtt/test_topic_match.cpp | 103 +++++++++++++++++++++ 3 files changed, 132 insertions(+), 57 deletions(-) create mode 100644 esphome/components/mqtt/mqtt_topic_match.h create mode 100644 tests/components/mqtt/test_topic_match.cpp diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index 297a94d2a97..dd376251399 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -16,6 +16,7 @@ #include "lwip/dns.h" #include "lwip/err.h" #include "mqtt_component.h" +#include "mqtt_topic_match.h" #ifdef USE_API #include "esphome/components/api/api_server.h" @@ -611,63 +612,6 @@ void MQTTClientComponent::disable() { this->on_shutdown(); } -/** Check if the message topic matches the given subscription topic - * - * INFO: MQTT spec mandates that topics must not be empty and must be valid NULL-terminated UTF-8 strings. - * - * @param message The message topic that was received from the MQTT server. Note: this must not contain - * wildcard characters as mandated by the MQTT spec. - * @param subscription The subscription topic we are matching against. - * @param is_normal Is this a "normal" topic - Does the message topic not begin with a "$". - * @param past_separator Are we past the first '/' topic separator. - * @return true if the subscription topic matches the message topic, false otherwise. - */ -static bool topic_match(const char *message, const char *subscription, bool is_normal, bool past_separator) { - // Reached end of both strings at the same time, this means we have a successful match - if (*message == '\0' && *subscription == '\0') - return true; - - // Either the message or the subscribe are at the end. This means they don't match. - if (*message == '\0' || *subscription == '\0') - return false; - - bool do_wildcards = is_normal || past_separator; - - if (*subscription == '+' && do_wildcards) { - // single level wildcard - // consume + from subscription - subscription++; - // consume everything from message until '/' found or end of string - while (*message != '\0' && *message != '/') { - message++; - } - // after this, both pointers will point to a '/' or to the end of the string - - return topic_match(message, subscription, is_normal, true); - } - - if (*subscription == '#' && do_wildcards) { - // multilevel wildcard - MQTT mandates that this must be at end of subscribe topic - return true; - } - - // this handles '/' and normal characters at the same time. - if (*message != *subscription) - return false; - - past_separator = past_separator || *subscription == '/'; - - // consume characters - subscription++; - message++; - - return topic_match(message, subscription, is_normal, past_separator); -} - -static bool topic_match(const char *message, const char *subscription) { - return topic_match(message, subscription, *message != '\0' && *message != '$', false); -} - void MQTTClientComponent::on_message(const std::string &topic, const std::string &payload) { #ifdef USE_ESP8266 // IMPORTANT: This defer is REQUIRED to prevent stack overflow crashes on ESP8266. diff --git a/esphome/components/mqtt/mqtt_topic_match.h b/esphome/components/mqtt/mqtt_topic_match.h new file mode 100644 index 00000000000..84172bc805e --- /dev/null +++ b/esphome/components/mqtt/mqtt_topic_match.h @@ -0,0 +1,28 @@ +#pragma once + +namespace esphome::mqtt { + +/// True if the message topic matches the subscription, with + and # wildcards. +/// A loop on purpose: recursing per character overflowed the ESP32 loop task's stack on long topics. +inline bool topic_match(const char *message, const char *subscription) { + // First-level wildcards do not match topics that begin with '$' + bool wildcards = *message != '$'; + while (*message != '\0' && *subscription != '\0') { + if (wildcards && *subscription == '#') + return true; + if (wildcards && *subscription == '+') { + subscription++; + while (*message != '\0' && *message != '/') + message++; + continue; + } + if (*message != *subscription) + return false; + wildcards |= *subscription == '/'; + message++; + subscription++; + } + return *message == *subscription; // a match only if both ended +} + +} // namespace esphome::mqtt diff --git a/tests/components/mqtt/test_topic_match.cpp b/tests/components/mqtt/test_topic_match.cpp new file mode 100644 index 00000000000..b51f45ce478 --- /dev/null +++ b/tests/components/mqtt/test_topic_match.cpp @@ -0,0 +1,103 @@ +#include + +#include +#include + +#include "esphome/components/mqtt/mqtt_topic_match.h" + +namespace esphome::mqtt::testing { + +// The previous recursive implementation, kept to check the loop matches it +static bool reference_match(const char *message, const char *subscription, bool is_normal, bool past_separator) { + if (*message == '\0' && *subscription == '\0') + return true; + if (*message == '\0' || *subscription == '\0') + return false; + bool do_wildcards = is_normal || past_separator; + if (*subscription == '+' && do_wildcards) { + subscription++; + while (*message != '\0' && *message != '/') + message++; + return reference_match(message, subscription, is_normal, true); + } + if (*subscription == '#' && do_wildcards) + return true; + if (*message != *subscription) + return false; + past_separator = past_separator || *subscription == '/'; + return reference_match(message + 1, subscription + 1, is_normal, past_separator); +} + +static bool reference_match(const char *message, const char *subscription) { + return reference_match(message, subscription, *message != '\0' && *message != '$', false); +} + +TEST(MQTTTopicMatch, Exact) { + EXPECT_TRUE(topic_match("a/b/c", "a/b/c")); + EXPECT_FALSE(topic_match("a/b/c", "a/b")); + EXPECT_FALSE(topic_match("a/b", "a/b/c")); + EXPECT_FALSE(topic_match("a/b/c", "a/b/d")); +} + +TEST(MQTTTopicMatch, SingleLevelWildcard) { + EXPECT_TRUE(topic_match("a/b/c", "a/+/c")); + EXPECT_TRUE(topic_match("a/b/c", "+/+/+")); + EXPECT_TRUE(topic_match("a//c", "a/+/c")); + EXPECT_FALSE(topic_match("a/b/c", "a/+")); + EXPECT_FALSE(topic_match("a/b/c/d", "a/+/c")); +} + +TEST(MQTTTopicMatch, MultiLevelWildcard) { + EXPECT_TRUE(topic_match("a/b/c", "a/#")); + EXPECT_TRUE(topic_match("a/b/c", "#")); + EXPECT_TRUE(topic_match("a/b/c", "a/+/#")); + EXPECT_FALSE(topic_match("b/c", "a/#")); +} + +TEST(MQTTTopicMatch, DollarTopicsSkipFirstLevelWildcards) { + EXPECT_FALSE(topic_match("$SYS/broker", "#")); + EXPECT_FALSE(topic_match("$SYS/broker", "+/broker")); + EXPECT_TRUE(topic_match("$SYS/broker", "$SYS/#")); + EXPECT_TRUE(topic_match("$SYS/broker", "$SYS/+")); +} + +TEST(MQTTTopicMatch, EdgeCases) { + EXPECT_TRUE(topic_match("", "")); + EXPECT_FALSE(topic_match("a", "")); + // Kept from the previous code; the MQTT spec has "#" match the parent level and "+" an empty last level + EXPECT_FALSE(topic_match("a/b", "a/b/#")); + EXPECT_FALSE(topic_match("a/", "a/+")); + EXPECT_TRUE(topic_match("a/$x", "a/+")); +} + +// Recursion overflowed the ESP32 loop stack at about 250 characters +TEST(MQTTTopicMatch, LongTopic) { + std::string topic(4096, 'x'); + topic[100] = '/'; + EXPECT_TRUE(topic_match(topic.c_str(), topic.c_str())); + std::string other = topic; + other.back() = 'y'; + EXPECT_FALSE(topic_match(topic.c_str(), other.c_str())); +} + +TEST(MQTTTopicMatch, MatchesPreviousImplementation) { + // Small alphabet so wildcards, separators and '$' meet often + static constexpr char MESSAGE_CHARS[] = {'a', 'b', '/', '$'}; + static constexpr char SUBSCRIPTION_CHARS[] = {'a', 'b', '/', '$', '+', '#'}; + std::mt19937 rng(1234); // NOLINT(cert-msc32-c,cert-msc51-cpp,bugprone-random-generator-seed) reproducible + auto random_string = [&rng](const char *chars, size_t count) { + std::string s(rng() % 8, ' '); + for (char &c : s) + c = chars[rng() % count]; + return s; + }; + for (int i = 0; i < 20000; i++) { + std::string message = random_string(MESSAGE_CHARS, sizeof(MESSAGE_CHARS)); + std::string subscription = random_string(SUBSCRIPTION_CHARS, sizeof(SUBSCRIPTION_CHARS)); + ASSERT_EQ(topic_match(message.c_str(), subscription.c_str()), + reference_match(message.c_str(), subscription.c_str())) + << "message='" << message << "' subscription='" << subscription << "'"; + } +} + +} // namespace esphome::mqtt::testing From cd97c9a9cb23d2a961215bd7a49c325da1cc95b7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:29:17 +0000 Subject: [PATCH 4/9] Bump platformdirs from 4.12.4 to 4.13.0 (#20772) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a247eb4ba15..9105f3f9c0a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,7 +31,7 @@ smp==4.2.0; platform_system != "Darwin" or platform_machine != "x86_64" smp==4.1.0; platform_system == "Darwin" and platform_machine == "x86_64" requests==2.34.2 py7zr==1.1.3 -platformdirs==4.12.4 # native esp-idf toolchain global cache dir +platformdirs==4.13.0 # native esp-idf toolchain global cache dir ninja==1.13.2 # native host and esp8266 arduino toolchain build driver filelock==4.1.0 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg From 61f3de0bc0b17377e33ffc66052a0f8ef7e09d6e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:30:26 +0000 Subject: [PATCH 5/9] Bump pylint from 4.1.2 to 4.1.3 (#20773) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 65e8a63bfe9..b36562727f8 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,4 +1,4 @@ -pylint==4.1.2 +pylint==4.1.3 flake8==7.4.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py ruff==0.17.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py pyupgrade==3.22.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py From 3df936087676b5f9ffb7cf73ef4808f589159715 Mon Sep 17 00:00:00 2001 From: "nullable.eth" <2248325+nullable-eth@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:39:30 -0400 Subject: [PATCH 6/9] [i2s_audio] Add keep_alive option to speaker to prevent pop/click on audio start (#15565) Co-authored-by: J. Nick Koston --- .../components/i2s_audio/speaker/__init__.py | 24 +++++++ .../i2s_audio/speaker/i2s_audio_speaker.cpp | 64 ++++++++++--------- .../i2s_audio/speaker/i2s_audio_speaker.h | 9 ++- .../speaker/i2s_audio_speaker_standard.cpp | 15 ++++- tests/component_tests/i2s_audio/__init__.py | 0 .../i2s_audio/config/keep_alive.yaml | 16 +++++ .../config/keep_alive_shared_bus.yaml | 19 ++++++ .../config/keep_alive_shared_mic.yaml | 22 +++++++ .../i2s_audio/config/keep_alive_spdif.yaml | 20 ++++++ .../component_tests/i2s_audio/test_config.py | 36 +++++++++++ 10 files changed, 191 insertions(+), 34 deletions(-) create mode 100644 tests/component_tests/i2s_audio/__init__.py create mode 100644 tests/component_tests/i2s_audio/config/keep_alive.yaml create mode 100644 tests/component_tests/i2s_audio/config/keep_alive_shared_bus.yaml create mode 100644 tests/component_tests/i2s_audio/config/keep_alive_shared_mic.yaml create mode 100644 tests/component_tests/i2s_audio/config/keep_alive_spdif.yaml create mode 100644 tests/component_tests/i2s_audio/test_config.py diff --git a/esphome/components/i2s_audio/speaker/__init__.py b/esphome/components/i2s_audio/speaker/__init__.py index 4dc15681bfa..e4ea186199d 100644 --- a/esphome/components/i2s_audio/speaker/__init__.py +++ b/esphome/components/i2s_audio/speaker/__init__.py @@ -1,6 +1,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import audio, esp32, speaker +from esphome.components.microphone import DOMAIN as MICROPHONE_DOMAIN from esphome.config_helpers import filter_source_files_from_defines import esphome.config_validation as cv from esphome.const import ( @@ -14,9 +15,11 @@ from esphome.const import ( CONF_SAMPLE_RATE, CONF_TIMEOUT, ) +import esphome.final_validate as fv from esphome.types import ConfigType from .. import ( + CONF_I2S_AUDIO_ID, CONF_I2S_DOUT_PIN, CONF_I2S_MODE, CONF_LEFT, @@ -44,6 +47,7 @@ I2SAudioSpeaker = i2s_audio_ns.class_("I2SAudioSpeaker", I2SAudioSpeakerBase) CONF_DAC_TYPE = "dac_type" CONF_I2S_COMM_FMT = "i2s_comm_fmt" +CONF_KEEP_ALIVE = "keep_alive" CONF_SPDIF_MODE = "spdif_mode" I2SAudioSpeakerBase = i2s_audio_ns.class_( @@ -173,6 +177,7 @@ BASE_SCHEMA = ( cv.positive_time_period_milliseconds, cv.one_of(CONF_NEVER, lower=True), ), + cv.Optional(CONF_KEEP_ALIVE, default=False): cv.boolean, } ) .extend(cv.COMPONENT_SCHEMA) @@ -235,6 +240,23 @@ def _final_validate(config: ConfigType) -> None: raise cv.Invalid("SPDIF mode requires 'i2s_comm_fmt: stand_i2s'") if config[CONF_MCLK_MULTIPLE] != 256: raise cv.Invalid("SPDIF mode requires 'mclk_multiple: 256'") + if config[CONF_KEEP_ALIVE]: + raise cv.Invalid("SPDIF mode does not support 'keep_alive'") + + if config[CONF_KEEP_ALIVE]: + # A kept channel holds the port between sessions, so nothing else may share the bus + bus = config[CONF_I2S_AUDIO_ID] + full_config = fv.full_config.get() + on_bus = sum( + 1 + for domain in (speaker.DOMAIN, MICROPHONE_DOMAIN) + for entry in full_config.get(domain, []) + if entry.get(CONF_I2S_AUDIO_ID) == bus + ) + if on_bus > 1: + raise cv.Invalid( + "'keep_alive' needs its own I2S bus; another speaker or microphone shares this one" + ) FINAL_VALIDATE_SCHEMA = _final_validate @@ -262,6 +284,8 @@ async def to_code(config: ConfigType) -> None: if config[CONF_TIMEOUT] != CONF_NEVER: cg.add(var.set_timeout(config[CONF_TIMEOUT])) cg.add(var.set_buffer_duration(config[CONF_BUFFER_DURATION])) + if config[CONF_KEEP_ALIVE]: + cg.add(var.set_keep_alive(True)) # The SPDIF encoder and speaker are fully #ifdef'd on USE_I2S_AUDIO_SPDIF_MODE, diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp index 8d586ea4e01..c644ece5428 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp @@ -56,8 +56,9 @@ void I2SAudioSpeakerBase::dump_config() { ESP_LOGCONFIG(TAG, "Speaker:\n" " Pin: %d\n" - " Buffer duration: %" PRIu32, - static_cast(this->dout_pin_), this->buffer_duration_ms_); + " Buffer duration: %" PRIu32 "\n" + " Keep alive: %s", + static_cast(this->dout_pin_), this->buffer_duration_ms_, YESNO(this->keep_alive_)); if (this->timeout_.has_value()) { ESP_LOGCONFIG(TAG, " Timeout: %" PRIu32 " ms", this->timeout_.value()); } @@ -105,7 +106,13 @@ void I2SAudioSpeakerBase::loop() { vTaskDelete(this->speaker_task_handle_); this->speaker_task_handle_ = nullptr; - this->stop_i2s_driver_(); + // The task raises this bit before it exits, possibly a loop pass before TASK_STOPPED is seen, so read it + // live here; a channel the task could not bring up is rebuilt next time rather than kept + const bool task_failed = xEventGroupGetBits(this->event_group_) & SpeakerEventGroupBits::ERR_ESP_NO_MEM; + if (task_failed) { + ESP_LOGE(TAG, "Speaker task setup failed (allocation, preload, or channel enable)"); + } + this->stop_i2s_driver_(this->keep_alive_ && !task_failed); // ALL_BITS includes COMMAND_START. Take the bits from the clear itself, not from the snapshot at // the top of loop(): the audio source's task can raise a start at any point above, including // during stop_i2s_driver_(), and nothing would ever re-issue it. @@ -121,11 +128,6 @@ void I2SAudioSpeakerBase::loop() { this->state_ = speaker::STATE_STOPPED; } - if (event_group_bits & SpeakerEventGroupBits::ERR_ESP_NO_MEM) { - ESP_LOGE(TAG, "Speaker task setup failed (allocation, preload, or channel enable)"); - xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM); - } - // Handle the speaker's state switch (this->state_) { case speaker::STATE_STARTING: @@ -133,8 +135,8 @@ void I2SAudioSpeakerBase::loop() { break; } - // Still starting up or winding down from a previous run - if ((this->tx_handle_ != nullptr) || (this->speaker_task_handle_ != nullptr)) { + // Still winding down from a previous run + if (this->speaker_task_handle_ != nullptr) { break; } @@ -154,7 +156,7 @@ void I2SAudioSpeakerBase::loop() { if (this->speaker_task_handle_ == nullptr) { ESP_LOGE(TAG, "Task failed to start, retrying in 1 second"); this->status_momentary_error(1000); - this->stop_i2s_driver_(); // Stops the driver to return the lock; will be reloaded in next attempt + this->stop_i2s_driver_(false); // Stops the driver to return the lock; will be reloaded in next attempt } break; case speaker::STATE_RUNNING: // Intentional fallthrough @@ -270,31 +272,25 @@ esp_err_t I2SAudioSpeakerBase::init_i2s_channel_(const i2s_chan_config_t &chan_c err = i2s_channel_init_std_mode(this->tx_handle_, &std_cfg); if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to initialize I2S channel"); - i2s_del_channel(this->tx_handle_); - this->tx_handle_ = nullptr; + this->delete_channel_(); this->parent_->unlock(); return err; } + // Both queues are drained by the speaker task before it preloads, whether the channel is new or kept if (this->i2s_event_queue_ == nullptr) { this->i2s_event_queue_ = xQueueCreate(event_queue_size, sizeof(int64_t)); - } else { - // Reset queue to clear any stale events from previous task - xQueueReset(this->i2s_event_queue_); } // Lockstep records queue. One record per in-flight DMA buffer; sized to match the I2S event queue // so a fully-saturated DMA pipeline cannot overflow either side before drain. if (this->write_records_queue_ == nullptr) { this->write_records_queue_ = xQueueCreate(event_queue_size, sizeof(uint32_t)); - } else { - xQueueReset(this->write_records_queue_); } if (this->i2s_event_queue_ == nullptr || this->write_records_queue_ == nullptr) { ESP_LOGE(TAG, "Failed to allocate I2S event queue(s)"); - i2s_del_channel(this->tx_handle_); - this->tx_handle_ = nullptr; + this->delete_channel_(); this->parent_->unlock(); return ESP_ERR_NO_MEM; } @@ -302,22 +298,30 @@ esp_err_t I2SAudioSpeakerBase::init_i2s_channel_(const i2s_chan_config_t &chan_c return ESP_OK; } -void I2SAudioSpeakerBase::stop_i2s_driver_() { +void I2SAudioSpeakerBase::stop_i2s_driver_(bool keep_channel) { if (this->tx_handle_ != nullptr) { i2s_channel_disable(this->tx_handle_); - i2s_del_channel(this->tx_handle_); - this->tx_handle_ = nullptr; - - // i2s_del_channel() leaves dout wired to this port's data-out signal in the GPIO matrix: it only - // clears an internal reservation mask, never the esp_rom_gpio_connect_out_signal() routing that - // setup installed. If another speaker reuses this port (shared bus), its audio still reaches our - // dout. Detach the pin and drive it low so a stale output stops driving downstream hardware: a - // SPDIF optical transmitter would otherwise stay lit, and an analog DAC would emit noise. - this->park_dout_pin_(); + // A kept channel lets the next start skip i2s_new_channel and i2s_channel_init_std_mode, whose GPIO and + // clock reconfiguration is the audible pop + if (!keep_channel) { + this->delete_channel_(); + } } this->parent_->unlock(); } +void I2SAudioSpeakerBase::delete_channel_() { + i2s_del_channel(this->tx_handle_); + this->tx_handle_ = nullptr; + + // i2s_del_channel() leaves dout wired to this port's data-out signal in the GPIO matrix: it only + // clears an internal reservation mask, never the esp_rom_gpio_connect_out_signal() routing that + // setup installed. If another speaker reuses this port (shared bus), its audio still reaches our + // dout. Detach the pin and drive it low so a stale output stops driving downstream hardware: a + // SPDIF optical transmitter would otherwise stay lit, and an analog DAC would emit noise. + this->park_dout_pin_(); +} + bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s_event_data_t *event, void *user_ctx) { int64_t now = esp_timer_get_time(); diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h index 965eede0201..d73d2da8794 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker.h @@ -54,6 +54,7 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public void set_buffer_duration(uint32_t buffer_duration_ms) { this->buffer_duration_ms_ = buffer_duration_ms; } void set_timeout(uint32_t ms) { this->timeout_ = ms; } + void set_keep_alive(bool keep_alive) { this->keep_alive_ = keep_alive; } void set_dout_pin(uint8_t pin) { this->dout_pin_ = (gpio_num_t) pin; } /// @brief Get the I2S TX channel handle @@ -126,12 +127,15 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public esp_err_t init_i2s_channel_(const i2s_chan_config_t &chan_cfg, const i2s_std_config_t &std_cfg, size_t event_queue_size); - /// @brief Stops the I2S driver and unlocks the I2S port - void stop_i2s_driver_(); + /// @brief Stops the I2S driver and unlocks the I2S port; keep_channel leaves the disabled channel allocated + void stop_i2s_driver_(bool keep_channel); /// @brief Detaches dout from the I2S signal and drives it low void park_dout_pin_(); + /// @brief Deletes the I2S channel and parks dout; the caller holds the bus lock + void delete_channel_(); + /// @brief Called in loop() when the task has stopped. Override for mode-specific cleanup. virtual void on_task_stopped() {} @@ -177,6 +181,7 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public optional timeout_; bool pause_state_{false}; + bool keep_alive_{false}; // Smooths software gain changes. The main loop posts targets, the speaker task processes; // GainRamp's mailbox makes that safe. The main loop is the only poster. diff --git a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp index fbc1edbde93..1a6b021fffa 100644 --- a/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp +++ b/esphome/components/i2s_audio/speaker/i2s_audio_speaker_standard.cpp @@ -75,6 +75,9 @@ void I2SAudioSpeaker::dump_config() { void I2SAudioSpeaker::run_speaker_task() { xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STARTING); + // Stale records from the previous run would pair with the wrong DMA events + xQueueReset(this->i2s_event_queue_); + xQueueReset(this->write_records_queue_); const uint32_t dma_buffers_duration_ms = DMA_BUFFER_DURATION_MS * DMA_BUFFERS_COUNT; // Ensure ring buffer duration is at least the duration of all DMA buffers @@ -389,8 +392,6 @@ void I2SAudioSpeaker::run_speaker_task() { } esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream_info) { - this->current_stream_info_ = audio_stream_info; - if ((this->i2s_role_ & I2S_ROLE_SLAVE) && (this->sample_rate_ != audio_stream_info.get_sample_rate())) { // NOLINT // Can't reconfigure I2S bus, so the sample rate must match the configured value ESP_LOGE(TAG, "Incompatible stream settings"); @@ -428,6 +429,16 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream return ESP_ERR_INVALID_STATE; } + // Reuse a kept channel while the stream format matches + if (this->tx_handle_ != nullptr) { + if (this->current_stream_info_ == audio_stream_info) { + return ESP_OK; + } + this->delete_channel_(); + } + + this->current_stream_info_ = audio_stream_info; + // The DMA buffers hold output-format (post-narrowing) samples, so size them from the output stream info. uint32_t dma_buffer_length = dma_buffer_frames(this->output_stream_info_); diff --git a/tests/component_tests/i2s_audio/__init__.py b/tests/component_tests/i2s_audio/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/i2s_audio/config/keep_alive.yaml b/tests/component_tests/i2s_audio/config/keep_alive.yaml new file mode 100644 index 00000000000..fd418bd4af5 --- /dev/null +++ b/tests/component_tests/i2s_audio/config/keep_alive.yaml @@ -0,0 +1,16 @@ +--- +esphome: + name: i2s-test + +esp32: + board: esp32dev + +i2s_audio: + i2s_bclk_pin: GPIO27 + i2s_lrclk_pin: GPIO26 + +speaker: + - platform: i2s_audio + dac_type: external + i2s_dout_pin: GPIO12 + keep_alive: true diff --git a/tests/component_tests/i2s_audio/config/keep_alive_shared_bus.yaml b/tests/component_tests/i2s_audio/config/keep_alive_shared_bus.yaml new file mode 100644 index 00000000000..2eecf2088fa --- /dev/null +++ b/tests/component_tests/i2s_audio/config/keep_alive_shared_bus.yaml @@ -0,0 +1,19 @@ +--- +esphome: + name: i2s-test + +esp32: + board: esp32dev + +i2s_audio: + i2s_bclk_pin: GPIO27 + i2s_lrclk_pin: GPIO26 + +speaker: + - platform: i2s_audio + dac_type: external + i2s_dout_pin: GPIO12 + keep_alive: true + - platform: i2s_audio + dac_type: external + i2s_dout_pin: GPIO13 diff --git a/tests/component_tests/i2s_audio/config/keep_alive_shared_mic.yaml b/tests/component_tests/i2s_audio/config/keep_alive_shared_mic.yaml new file mode 100644 index 00000000000..c95ff1abbc6 --- /dev/null +++ b/tests/component_tests/i2s_audio/config/keep_alive_shared_mic.yaml @@ -0,0 +1,22 @@ +--- +esphome: + name: i2s-test + +esp32: + board: esp32dev + +i2s_audio: + i2s_bclk_pin: GPIO27 + i2s_lrclk_pin: GPIO26 + +speaker: + - platform: i2s_audio + dac_type: external + i2s_dout_pin: GPIO12 + keep_alive: true + +microphone: + - platform: i2s_audio + i2s_din_pin: GPIO34 + adc_type: external + pdm: false diff --git a/tests/component_tests/i2s_audio/config/keep_alive_spdif.yaml b/tests/component_tests/i2s_audio/config/keep_alive_spdif.yaml new file mode 100644 index 00000000000..0e194bebef5 --- /dev/null +++ b/tests/component_tests/i2s_audio/config/keep_alive_spdif.yaml @@ -0,0 +1,20 @@ +--- +esphome: + name: i2s-test + +esp32: + board: esp32dev + +i2s_audio: + i2s_bclk_pin: GPIO27 + i2s_lrclk_pin: GPIO26 + +speaker: + - platform: i2s_audio + dac_type: external + i2s_dout_pin: GPIO12 + spdif_mode: true + use_apll: true + sample_rate: 48000 + channel: stereo + keep_alive: true diff --git a/tests/component_tests/i2s_audio/test_config.py b/tests/component_tests/i2s_audio/test_config.py new file mode 100644 index 00000000000..bd5c3a598fa --- /dev/null +++ b/tests/component_tests/i2s_audio/test_config.py @@ -0,0 +1,36 @@ +"""Exercise the I2S speaker keep_alive validation.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.config import read_config +from esphome.core import CORE + + +def test_keep_alive_generates_setter( + generate_main: Callable[[Path], str], + component_config_path: Callable[[str], Path], +) -> None: + source = generate_main(component_config_path("keep_alive.yaml")) + assert "set_keep_alive(true)" in source + + +@pytest.mark.parametrize( + ("config_file", "message"), + [ + ("keep_alive_shared_bus.yaml", "needs its own I2S bus"), + ("keep_alive_shared_mic.yaml", "needs its own I2S bus"), + ("keep_alive_spdif.yaml", "SPDIF mode does not support 'keep_alive'"), + ], +) +def test_keep_alive_rejected( + component_config_path: Callable[[str], Path], + capsys: pytest.CaptureFixture[str], + config_file: str, + message: str, +) -> None: + CORE.config_path = component_config_path(config_file) + assert read_config({}) is None + assert message in capsys.readouterr().out From 04715fb32bf711b89b04bb606fad55f977f0fcd1 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:41:12 -0400 Subject: [PATCH 7/9] [usb_uart] Expose `interface_class` and `port_number` (#20659) Co-authored-by: J. Nick Koston --- esphome/components/api/api.proto | 7 ++- esphome/components/api/api_pb2.cpp | 4 ++ esphome/components/api/api_pb2.h | 2 + esphome/components/api/api_pb2_dump.cpp | 2 + .../components/serial_proxy/serial_proxy.cpp | 2 + esphome/components/usb_uart/ch34x.cpp | 8 --- esphome/components/usb_uart/cp210x.cpp | 10 ++-- esphome/components/usb_uart/ft23xx.cpp | 1 + esphome/components/usb_uart/pl2303.cpp | 5 +- esphome/components/usb_uart/usb_uart.cpp | 6 ++- esphome/components/usb_uart/usb_uart.h | 21 +++++++- tests/components/serial_proxy/__init__.py | 16 ++++++ .../test_usb_device_descriptor.cpp | 51 +++++++++++++++++++ 13 files changed, 115 insertions(+), 20 deletions(-) create mode 100644 tests/components/serial_proxy/__init__.py create mode 100644 tests/components/serial_proxy/test_usb_device_descriptor.cpp diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index f8f31d81df0..d55cf10c47d 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -3043,12 +3043,15 @@ enum SerialProxyIdentityFlag { // IDs below are empty } -// The descriptor fields of a USB device as seen by the host stack +// The descriptor fields of a USB device as seen by the host stack, and the port's index +// among those a vendor driver exposes on the interface message UsbDeviceDescriptor { uint32 vendor_id = 1; uint32 product_id = 2; uint32 bcd_device = 3; - uint32 interface_number = 4; // bInterfaceNumber the host driver binds to + uint32 interface_number = 4; // bInterfaceNumber the port is bound through + uint32 interface_class = 5; // bInterfaceClass of that interface + uint32 port_number = 6; } // The identity of the device behind a port. Identifies one physical device among others of diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 8847249b173..bcfa86763a1 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -4175,6 +4175,8 @@ uint8_t *UsbDeviceDescriptor::encode_msg(const void *self, uint8_t *__restrict__ pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, msg.product_id); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, msg.bcd_device); pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 4, msg.interface_number); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 5, msg.interface_class); + pos = ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, msg.port_number); return pos; } uint32_t UsbDeviceDescriptor::calc_size_msg(const void *self) { @@ -4184,6 +4186,8 @@ uint32_t UsbDeviceDescriptor::calc_size_msg(const void *self) { size += ProtoSize::calc_uint32(1, msg.product_id); size += ProtoSize::calc_uint32(1, msg.bcd_device); size += ProtoSize::calc_uint32(1, msg.interface_number); + size += ProtoSize::calc_uint32(1, msg.interface_class); + size += ProtoSize::calc_uint32(1, msg.port_number); return size; } uint8_t *SerialProxyIdentity::encode_msg(const void *self, uint8_t *__restrict__ pos PROTO_ENCODE_DEBUG_PARAM) { diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 14204e97aaa..7c8082a8c7c 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -4083,6 +4083,8 @@ class UsbDeviceDescriptor final : public ProtoMessage { uint32_t product_id{0}; uint32_t bcd_device{0}; uint32_t interface_number{0}; + uint32_t interface_class{0}; + uint32_t port_number{0}; static uint8_t *encode_msg(const void *self, uint8_t *pos PROTO_ENCODE_DEBUG_PARAM); uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { return encode_msg(this, buffer.get_pos() PROTO_ENCODE_DEBUG_ARG); diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 3c6f5302b55..ec224c46e29 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -2942,6 +2942,8 @@ const char *UsbDeviceDescriptor::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("product_id"), this->product_id); dump_field(out, ESPHOME_PSTR("bcd_device"), this->bcd_device); dump_field(out, ESPHOME_PSTR("interface_number"), this->interface_number); + dump_field(out, ESPHOME_PSTR("interface_class"), this->interface_class); + dump_field(out, ESPHOME_PSTR("port_number"), this->port_number); return out.c_str(); } const char *SerialProxyIdentity::dump_to(DumpBuffer &out) const { diff --git a/esphome/components/serial_proxy/serial_proxy.cpp b/esphome/components/serial_proxy/serial_proxy.cpp index 9623eb21b3c..03d289225f2 100644 --- a/esphome/components/serial_proxy/serial_proxy.cpp +++ b/esphome/components/serial_proxy/serial_proxy.cpp @@ -418,6 +418,8 @@ void SerialProxy::fill_identity_([[maybe_unused]] IdentityScratch &scratch, api: msg.usb.product_id = scratch.product_id; msg.usb.bcd_device = scratch.bcd_device; msg.usb.interface_number = this->usb_channel_->get_interface_number(); + msg.usb.interface_class = this->usb_channel_->get_interface_class(); + msg.usb.port_number = this->usb_channel_->get_port_number(); msg.manufacturer = StringRef(scratch.manufacturer); msg.product = StringRef(scratch.product); msg.serial_number = StringRef(scratch.serial_number); diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index ed5a034b82f..dce07a64a53 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -152,14 +152,6 @@ bool USBUartTypeCH34X::config_step(USBUartChannelBase *channel, uint8_t step, bo } } -std::vector USBUartTypeCH34X::parse_descriptors(usb_device_handle_t dev_hdl) { - auto result = USBUartTypeCdcAcm::parse_descriptors(dev_hdl); - // ch34x doesn't use the interrupt endpoint, and we don't have endpoints to spare - for (auto &cdc_dev : result) { - cdc_dev.interrupt_interface_number = 0xFF; - } - return result; -} } // namespace esphome::usb_uart #endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 || diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index e920da6c267..34b76f88541 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -88,11 +88,11 @@ std::vector USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev ESP_LOGE(TAG, "in_ep: usb_parse_endpoint_descriptor_by_index failed"); continue; } - if (in_ep->bEndpointAddress & usb_host::USB_DIR_IN) { - cdc_devs.push_back({CdcEps{nullptr, in_ep, out_ep, data_desc->bInterfaceNumber}}); - } else { - cdc_devs.push_back({CdcEps{nullptr, out_ep, in_ep, data_desc->bInterfaceNumber}}); - } + CdcEps eps = in_ep->bEndpointAddress & usb_host::USB_DIR_IN + ? CdcEps{nullptr, in_ep, out_ep, data_desc->bInterfaceNumber} + : CdcEps{nullptr, out_ep, in_ep, data_desc->bInterfaceNumber}; + eps.bulk_interface_class = data_desc->bInterfaceClass; + cdc_devs.push_back(eps); } return cdc_devs; } diff --git a/esphome/components/usb_uart/ft23xx.cpp b/esphome/components/usb_uart/ft23xx.cpp index e10da78aaf0..805a4adfce3 100644 --- a/esphome/components/usb_uart/ft23xx.cpp +++ b/esphome/components/usb_uart/ft23xx.cpp @@ -217,6 +217,7 @@ static optional get_uart(const usb_config_desc_t *config_desc, uint8_t i } eps.bulk_interface_number = intf_desc->bInterfaceNumber; + eps.bulk_interface_class = intf_desc->bInterfaceClass; return eps; } diff --git a/esphome/components/usb_uart/pl2303.cpp b/esphome/components/usb_uart/pl2303.cpp index 36107c093c4..0430b1eeacb 100644 --- a/esphome/components/usb_uart/pl2303.cpp +++ b/esphome/components/usb_uart/pl2303.cpp @@ -189,7 +189,10 @@ std::vector USBUartTypePL2303::parse_descriptors(usb_device_handle_t dev } if (in_ep && out_ep) { - cdc_devs.push_back(CdcEps{notify_ep, in_ep, out_ep, intf->bInterfaceNumber, intf->bInterfaceNumber}); + CdcEps eps{notify_ep, in_ep, out_ep, intf->bInterfaceNumber, intf->bInterfaceNumber}; + eps.bulk_interface_class = intf->bInterfaceClass; + eps.interrupt_interface_class = intf->bInterfaceClass; + cdc_devs.push_back(eps); break; // PL2303 is single-port } } diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index f61642566cb..744b79f4e43 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -41,14 +41,17 @@ static optional get_cdc(const usb_config_desc_t *config_desc, uint8_t in if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_INT) { eps.notify_ep = ep; eps.interrupt_interface_number = intf_desc->bInterfaceNumber; + eps.interrupt_interface_class = intf_desc->bInterfaceClass; } else if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_BULK && ep->bEndpointAddress & usb_host::USB_DIR_IN && (eps.bulk_interface_number == 0xFF || eps.bulk_interface_number == intf_desc->bInterfaceNumber)) { eps.in_ep = ep; eps.bulk_interface_number = intf_desc->bInterfaceNumber; + eps.bulk_interface_class = intf_desc->bInterfaceClass; } else if (ep->bmAttributes == USB_BM_ATTRIBUTES_XFER_BULK && !(ep->bEndpointAddress & usb_host::USB_DIR_IN) && (eps.bulk_interface_number == 0xFF || eps.bulk_interface_number == intf_desc->bInterfaceNumber)) { eps.out_ep = ep; eps.bulk_interface_number = intf_desc->bInterfaceNumber; + eps.bulk_interface_class = intf_desc->bInterfaceClass; } else { ESP_LOGE(TAG, "Unexpected endpoint attributes: %02X", ep->bmAttributes); continue; @@ -431,8 +434,7 @@ void USBUartTypeCdcAcm::on_connected() { if (channel->cdc_dev_.interrupt_interface_number != 0xFF && channel->cdc_dev_.interrupt_interface_number != channel->cdc_dev_.bulk_interface_number) { if (!this->claim_comm_interface_) { - ESP_LOGD(TAG, "Skipping comm interface %d (claim_comm_interface: false)", - channel->cdc_dev_.interrupt_interface_number); + ESP_LOGD(TAG, "Comm interface %d left unclaimed", channel->cdc_dev_.interrupt_interface_number); } else { auto err_comm = usb_host_interface_claim(this->handle_, this->device_handle_, channel->cdc_dev_.interrupt_interface_number, 0); diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index b54641884e6..ba073404e2f 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -40,6 +40,9 @@ struct CdcEps { // must remain valid even when the interface itself is not claimed. uint8_t interrupt_interface_number{0xFF}; bool interrupt_interface_claimed{false}; + // bInterfaceClass of each interface above, which decides how a host binds the port + uint8_t bulk_interface_class{0}; + uint8_t interrupt_interface_class{0}; }; enum CH34xChipType : uint8_t { @@ -187,6 +190,16 @@ class USBUartChannelBase : public uart::UARTComponent, public Parentedcdc_dev_.bulk_interface_number; } + /// bInterfaceClass of the interface `get_interface_number()` names + uint8_t get_interface_class() const { + return this->cdc_dev_.interrupt_interface_number != 0xFF ? this->cdc_dev_.interrupt_interface_class + : this->cdc_dev_.bulk_interface_class; + } + + /// Index of this port among the ports on its interface. Every driver here exposes one + /// port per interface; a multiplexed driver would index its channels. + uint8_t get_port_number() const { return 0; } + protected: // Not directly instantiable; construct a concrete channel type instead. USBUartChannelBase(uint8_t index, uint16_t buffer_size) : input_buffer_(RingBuffer(buffer_size)), index_(index) {} @@ -318,13 +331,17 @@ class USBUartTypeCP210X : public USBUartTypeCdcAcm { }; class USBUartTypeCH34X : public USBUartTypeCdcAcm { public: - USBUartTypeCH34X(uint16_t vid, uint16_t pid) : USBUartTypeCdcAcm(vid, pid) {} + USBUartTypeCH34X(uint16_t vid, uint16_t pid) : USBUartTypeCdcAcm(vid, pid) { + // ch34x doesn't use the interrupt endpoint, and we don't have endpoints to spare. The + // comm interface is left in place unclaimed: on the CDC-style chips it is still the + // interface a host binds, and so the one the port is identified by. + this->claim_comm_interface_ = false; + } void dump_config() override; protected: bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override; bool config_device_step(uint8_t step, bool ok, const uint8_t *response) override; - std::vector parse_descriptors(usb_device_handle_t dev_hdl) override; private: CH34xChipType chiptype_{CHIP_UNKNOWN}; diff --git a/tests/components/serial_proxy/__init__.py b/tests/components/serial_proxy/__init__.py new file mode 100644 index 00000000000..913e8d8b37e --- /dev/null +++ b/tests/components/serial_proxy/__init__.py @@ -0,0 +1,16 @@ +import esphome.codegen as cg +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # The harness gives a multi-instance component an empty list and never calls its to_code, + # so take a single empty config instead and emit what the proxy messages and handlers + # need. No proxy is configured, so the count is 0, which keeps the capability messages as + # the api tests expect them; the USB identity stays out, since the USB channel exists on the ESP32 only. + manifest.multi_conf = False + + async def to_code_testing(config): + cg.add_define("USE_SERIAL_PROXY") + cg.add_define("SERIAL_PROXY_COUNT", 0) + + manifest.to_code = to_code_testing diff --git a/tests/components/serial_proxy/test_usb_device_descriptor.cpp b/tests/components/serial_proxy/test_usb_device_descriptor.cpp new file mode 100644 index 00000000000..c76757bbee6 --- /dev/null +++ b/tests/components/serial_proxy/test_usb_device_descriptor.cpp @@ -0,0 +1,51 @@ +#include + +#include +#include + +#include "esphome/components/api/api_buffer.h" +#include "esphome/components/api/api_pb2.h" +#include "esphome/components/api/proto.h" + +namespace esphome::api::testing { + +// Encodes into a buffer of exactly the calculated size, so any overrun trips the debug bounds check. +static std::vector encode(const UsbDeviceDescriptor &msg) { + const uint32_t size = msg.calculate_size(); + APIBuffer buf; + EXPECT_TRUE(buf.resize(size == 0 ? 1 : size)); + uint8_t *pos = buf.data(); +#ifdef ESPHOME_DEBUG_API + uint8_t *proto_debug_end_ = buf.data() + buf.size(); +#endif + uint8_t *end = UsbDeviceDescriptor::encode_msg(&msg, pos PROTO_ENCODE_DEBUG_ARG); + EXPECT_EQ(static_cast(end - buf.data()), size); + return {buf.data(), end}; +} + +TEST(UsbDeviceDescriptor, EncodesTheInterfaceClassAndPort) { + UsbDeviceDescriptor msg; + msg.vendor_id = 0x10C4; + msg.product_id = 0xEA60; + msg.bcd_device = 0x0100; + msg.interface_number = 1; + msg.interface_class = 0xFF; + msg.port_number = 2; + + const std::vector expected = {0x08, 0xC4, 0x21, 0x10, 0xE0, 0xD4, 0x03, 0x18, 0x80, + 0x02, 0x20, 0x01, 0x28, 0xFF, 0x01, 0x30, 0x02}; + EXPECT_EQ(encode(msg), expected); +} + +TEST(UsbDeviceDescriptor, ZeroClassAndPortLeaveTheWireAsBefore) { + // The new fields are additive with zero defaults, so an old client sees the message it always did + UsbDeviceDescriptor msg; + msg.vendor_id = 0x303A; + msg.product_id = 0x1001; + msg.interface_number = 1; + + const std::vector expected = {0x08, 0xBA, 0x60, 0x10, 0x81, 0x20, 0x20, 0x01}; + EXPECT_EQ(encode(msg), expected); +} + +} // namespace esphome::api::testing From 2c6f59738cf868b38cd76675f6cb948025f75fc6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:41:28 +0000 Subject: [PATCH 8/9] Bump filelock from 4.1.0 to 4.1.1 (#20778) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9105f3f9c0a..ca5c5af39ac 100644 --- a/requirements.txt +++ b/requirements.txt @@ -33,7 +33,7 @@ requests==2.34.2 py7zr==1.1.3 platformdirs==4.13.0 # native esp-idf toolchain global cache dir ninja==1.13.2 # native host and esp8266 arduino toolchain build driver -filelock==4.1.0 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg +filelock==4.1.1 # 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.3 From dcb943ded39dc1a7ec470dbfa7f461fc5041ab65 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 11:45:54 -1000 Subject: [PATCH 9/9] [noise] Initialize the protocol id's reserved field for ESP-IDF 6.1 (#20779) --- esphome/components/noise/noise_handshake.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/esphome/components/noise/noise_handshake.cpp b/esphome/components/noise/noise_handshake.cpp index 181dd63bb21..46a1314d23c 100644 --- a/esphome/components/noise/noise_handshake.cpp +++ b/esphome/components/noise/noise_handshake.cpp @@ -38,6 +38,7 @@ int NoiseResponderHandshake::init(const NoiseContext &ctx, const uint8_t *prolog .cipher_id = NOISE_CIPHER_CHACHAPOLY, .hash_id = NOISE_HASH_SHA256, .hybrid_id = NOISE_DH_NONE, + .reserved = {}, }; int err = noise_handshakestate_new_by_id(&this->handshake_, &nid, NOISE_ROLE_RESPONDER);