From 1779b011c9bbb55b0d441f8296b740189487e4e4 Mon Sep 17 00:00:00 2001 From: "esphome[bot]" <115708604+esphome[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:21:45 +0000 Subject: [PATCH 1/5] Bump aioesphomeapi from 46.10.0 to 46.11.0 (#20766) Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 5397959f63f..a247eb4ba15 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.2.0 esptool==5.5.0 click==8.5.0 -aioesphomeapi==46.10.0 +aioesphomeapi==46.11.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.151.5 puremagic==2.2.0 From 6d173c7371a00f76e39d1f46faaa5cdd2ec274d5 Mon Sep 17 00:00:00 2001 From: Panoplian <153834574+albert-canfield@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:35:01 +0100 Subject: [PATCH 2/5] [bq25186] Set i2c_id in the component tests (#20742) --- tests/components/bq25186/common.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/components/bq25186/common.yaml b/tests/components/bq25186/common.yaml index 295a68d262b..916fedec3d1 100644 --- a/tests/components/bq25186/common.yaml +++ b/tests/components/bq25186/common.yaml @@ -12,6 +12,7 @@ esphome: bq25186: - id: bq25186_charger + i2c_id: i2c_bus configure_on_boot: false fast_charge_current: 150mA battery_regulation_voltage: 4.2V @@ -20,6 +21,7 @@ bq25186: charge_enabled: true # Second charger with the boot-time write enabled - id: bq25186_boot + i2c_id: i2c_bus address: 0x6B configure_on_boot: true fast_charge_current: 20mA From a4278b8decaa087f593f4a50418e8b2167e2df31 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 14 Oct 2026 10:35:33 -1000 Subject: [PATCH 3/5] [ci] Run benchmarks when a component the Python import benchmark imports changes (#20763) --- script/determine-jobs.py | 68 ++++++++++++++++++++++++ tests/script/test_determine_jobs.py | 82 +++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+) diff --git a/script/determine-jobs.py b/script/determine-jobs.py index 9d146c94a0b..e1483446d4d 100755 --- a/script/determine-jobs.py +++ b/script/determine-jobs.py @@ -49,6 +49,7 @@ Options: from __future__ import annotations import argparse +import ast from collections import Counter from collections.abc import Callable from enum import StrEnum @@ -58,6 +59,7 @@ import math import os from pathlib import Path import statistics +import subprocess import sys from typing import Any @@ -69,6 +71,7 @@ from clang_tidy_hash import ( ) from helpers import ( CPP_FILE_EXTENSIONS, + ESPHOME_COMPONENTS_PATH, ESPHOME_TESTS_COMPONENTS_PATH, INTEGRATION_TESTS_PATH, PYTHON_FILE_EXTENSIONS, @@ -805,6 +808,9 @@ def determine_cpp_unit_tests( # Paths within tests/benchmarks/ that contain component benchmark files BENCHMARKS_COMPONENTS_PATH = "tests/benchmarks/components" +# The Python benchmark that imports components; its COMPONENTS tuple says which ones +PYTHON_IMPORT_BENCHMARK_FILE = "tests/benchmarks/python/test_component_import_bench.py" + # Files that, when changed, should trigger benchmark runs BENCHMARK_INFRASTRUCTURE_FILES = frozenset( { @@ -815,6 +821,53 @@ BENCHMARK_INFRASTRUCTURE_FILES = frozenset( ) +def _benchmark_components_tuple(path: Path) -> frozenset[str]: + """Return the benchmark file's ``COMPONENTS`` tuple. + + Raises when the file has none, so renaming it fails CI instead of quietly + turning the trigger off. + """ + for node in ast.parse(path.read_text(encoding="utf-8")).body: + if isinstance(node, ast.Assign) and any( + isinstance(t, ast.Name) and t.id == "COMPONENTS" for t in node.targets + ): + return frozenset(ast.literal_eval(node.value)) + raise RuntimeError(f"{PYTHON_IMPORT_BENCHMARK_FILE} has no COMPONENTS tuple") + + +# Imports the given components and prints every component whose module got loaded +_IMPORT_PROBE = ( + "import importlib, sys\n" + "for c in sys.argv[1:]: importlib.import_module('esphome.components.' + c)\n" + "print(*{n.split('.')[2] for n in sys.modules if n.startswith('esphome.components.')})" +) + + +def python_import_benchmarked_components() -> frozenset[str] | None: + """Return the components loaded by importing the benchmark's ``COMPONENTS``. + + Imported in a fresh interpreter from the checkout, so the set is exactly what + the benchmark times. None when the import fails. + """ + path = Path(root_path) / PYTHON_IMPORT_BENCHMARK_FILE + if not path.is_file(): + return frozenset() + result = subprocess.run( + [sys.executable, "-c", _IMPORT_PROBE, *_benchmark_components_tuple(path)], + cwd=root_path, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + print( + f"Benchmark import probe failed, running benchmarks:\n{result.stderr}", + file=sys.stderr, + ) + return None + return frozenset(result.stdout.split()) + + def should_run_benchmarks(branch: str | None = None) -> bool: """Determine if benchmarks (C++ and Python) should run based on changed files. @@ -831,6 +884,8 @@ def should_run_benchmarks(branch: str | None = None) -> bool: 4. A directly changed component has benchmark files (no dependency expansion) 5. Benchmark infrastructure changed (tests/benchmarks/*, script/cpp_benchmark.py, script/build_helpers.py, script/setup_codspeed_lib.py) + 6. A Python file changed in a component that importing the Python import + benchmark's ``COMPONENTS`` loads, even if it has no C++ benchmark Unlike unit tests, benchmarks do NOT expand to dependent components. Changing ``sensor`` does not trigger ``api`` benchmarks just because @@ -862,6 +917,19 @@ def should_run_benchmarks(branch: str | None = None) -> bool: ): return True + # The Python import benchmark times importing its components and what they pull + # in, so their Python sources move its numbers even without a C++ benchmark. + # A failed import runs the benchmarks rather than risk skipping them. + changed_python = { + get_component_from_path(f) + for f in files + if f.startswith(ESPHOME_COMPONENTS_PATH) and f.endswith(PYTHON_FILE_EXTENSIONS) + } + if changed_python: + loaded = python_import_benchmarked_components() + if loaded is None or changed_python & loaded: + return True + # Check if any directly changed component has benchmarks benchmarks_dir = Path(root_path) / BENCHMARKS_COMPONENTS_PATH if not benchmarks_dir.is_dir(): diff --git a/tests/script/test_determine_jobs.py b/tests/script/test_determine_jobs.py index e63e0849deb..6b63712f2a4 100644 --- a/tests/script/test_determine_jobs.py +++ b/tests/script/test_determine_jobs.py @@ -4,6 +4,7 @@ from collections.abc import Generator import importlib.util import json from pathlib import Path +import subprocess import sys from unittest.mock import Mock, patch @@ -2784,6 +2785,87 @@ def test_should_run_benchmarks_core_benchmark_file_change() -> None: assert determine_jobs.should_run_benchmarks() is True +def test_python_import_benchmarked_components_imports_real_components() -> None: + """The set is what importing the real benchmark's components loads.""" + loaded = determine_jobs.python_import_benchmarked_components() + # mqtt is not listed in COMPONENTS; sensor imports it at module scope + assert {"binary_sensor", "sensor", "logger", "mqtt"} <= loaded + + +def test_should_run_benchmarks_python_import_benchmarked_component() -> None: + """A Python change in a component the import benchmark loads runs benchmarks. + + logger has no C++ benchmark, so before this a change to its __init__.py + skipped the Python import benchmark that times it. + """ + for changed, expected in [ + ("esphome/components/logger/__init__.py", True), + ("esphome/components/logger/select/__init__.py", True), + ("esphome/components/mqtt/__init__.py", True), + ("esphome/components/logger/logger.cpp", False), + ("tests/components/logger/common.yaml", False), + ("esphome/components/wifi/__init__.py", False), + ]: + with ( + patch.object(determine_jobs, "changed_files", return_value=[changed]), + patch.object( + determine_jobs, + "python_import_benchmarked_components", + return_value=frozenset({"logger", "mqtt"}), + ), + ): + assert determine_jobs.should_run_benchmarks() is expected, changed + + +def test_should_run_benchmarks_when_import_probe_fails() -> None: + """A component import that fails runs the benchmarks instead of skipping them.""" + with ( + patch.object( + determine_jobs, + "changed_files", + return_value=["esphome/components/wifi/__init__.py"], + ), + patch.object( + determine_jobs, "python_import_benchmarked_components", return_value=None + ), + ): + assert determine_jobs.should_run_benchmarks() is True + + +def test_python_import_benchmarked_components_logs_probe_failure( + capsys: pytest.CaptureFixture[str], +) -> None: + """A failed probe returns None and prints why on stderr, never stdout.""" + failed = subprocess.CompletedProcess( + args=[], returncode=1, stdout="", stderr="ModuleNotFoundError: boom" + ) + with patch.object(determine_jobs.subprocess, "run", return_value=failed): + assert determine_jobs.python_import_benchmarked_components() is None + captured = capsys.readouterr() + assert "ModuleNotFoundError: boom" in captured.err + assert captured.out == "" + + +def test_python_import_benchmarked_components_missing_file(tmp_path: Path) -> None: + """Without the benchmark file no component is treated as benchmarked.""" + with patch.object(determine_jobs, "root_path", str(tmp_path)): + assert determine_jobs.python_import_benchmarked_components() == frozenset() + + +def test_python_import_benchmarked_components_requires_components( + tmp_path: Path, +) -> None: + """A benchmark file without COMPONENTS fails loudly instead of disabling the rule.""" + bench = tmp_path / determine_jobs.PYTHON_IMPORT_BENCHMARK_FILE + bench.parent.mkdir(parents=True) + bench.write_text('NAMES = ("logger",)\n') + with ( + patch.object(determine_jobs, "root_path", str(tmp_path)), + pytest.raises(RuntimeError, match="COMPONENTS"), + ): + determine_jobs.python_import_benchmarked_components() + + def test_should_run_benchmarks_benchmarked_component_change(tmp_path: Path) -> None: """Test benchmarks trigger when a benchmarked component changes.""" # Create a fake benchmarks directory with an 'api' component From c56bb140379962d69d6f02e8a683a64bdfef40a5 Mon Sep 17 00:00:00 2001 From: AndreKR Date: Wed, 14 Oct 2026 22:39:55 +0200 Subject: [PATCH 4/5] [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 5/5] [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{}); }