From 29d8f9c7c6c01c8129ab1711e6ac1b7fe4d213ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 3 Sep 2026 11:28:32 +0200 Subject: [PATCH] Name the reboot cause and log the loaded target at setup --- esphome/components/api/api_outgoing_connection.cpp | 5 ++++- esphome/components/api/api_server.cpp | 10 +++++++++- esphome/components/api/api_server.h | 6 +++++- tests/integration/test_api_reboot_timeout.py | 4 +++- 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/api_outgoing_connection.cpp b/esphome/components/api/api_outgoing_connection.cpp index f1d25ade8f..3fb28ad43b 100644 --- a/esphome/components/api/api_outgoing_connection.cpp +++ b/esphome/components/api/api_outgoing_connection.cpp @@ -23,8 +23,11 @@ void OutgoingConnectionManager::setup() { this->target_pref_ = global_preferences->make_preference(629847102UL, true); if (this->target_pref_.load(&this->saved_)) { this->host_persisted_ = true; + ESP_LOGD(TAG, "Loaded target %s", this->saved_.host); } else { - this->saved_ = {}; // dump_config() reports the empty state + // Never saved, or the blob failed its size/CRC check + ESP_LOGD(TAG, "No saved target"); + this->saved_ = {}; } // Defend against a corrupt or truncated preference blob this->saved_.host[sizeof(this->saved_.host) - 1] = '\0'; diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index b32c4af704..24df48ceba 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -160,7 +160,12 @@ void APIServer::loop() { if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) { const uint32_t now = App.get_loop_component_start_time(); if (now - this->last_connected_ > this->reboot_timeout_) { - ESP_LOGE(TAG, "No clients; rebooting"); + // Distinguish a wrong-key peer from nothing connecting at all + if (this->saw_unauthenticated_client_) { + ESP_LOGE(TAG, "Clients connected but none authenticated; rebooting"); + } else { + ESP_LOGE(TAG, "No clients; rebooting"); + } App.reboot(); } } @@ -245,6 +250,9 @@ void APIServer::remove_client_(uint8_t client_index) { // healthy session's timestamp and trigger a spurious reboot if (was_authenticated) { this->last_connected_ = App.get_loop_component_start_time(); + this->saw_unauthenticated_client_ = false; + } else { + this->saw_unauthenticated_client_ = true; } if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) { this->status_set_warning(LOG_STR("waiting for client connection")); diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index b50134c93e..0ccfab1cf9 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -364,7 +364,11 @@ class APIServer final : public Component, // Connection limits - these defaults will be overridden by config values // from cv.SplitDefault in __init__.py which sets platform-specific defaults. uint8_t listen_backlog_{4}; - bool shutting_down_ = false; + // Bit-packed so the two flags share one byte + bool shutting_down_ : 1 = false; + // For the reboot log: whether any removal since the last watchdog refresh + // was an unauthenticated session (e.g. a wrong-key peer) + bool saw_unauthenticated_client_ : 1 = false; uint8_t api_connection_count_{0}; #ifdef USE_API_OUTGOING_CONNECTION // Connected clients whose hello declared them a dial-back target diff --git a/tests/integration/test_api_reboot_timeout.py b/tests/integration/test_api_reboot_timeout.py index 9cada0a296..953fe47648 100644 --- a/tests/integration/test_api_reboot_timeout.py +++ b/tests/integration/test_api_reboot_timeout.py @@ -16,7 +16,9 @@ async def test_api_reboot_timeout( """Test that the device reboots when no API clients connect within the timeout.""" loop = asyncio.get_running_loop() reboot_future = loop.create_future() - reboot_pattern = re.compile(r"No clients; rebooting") + # The harness port probe counts as an unauthenticated client, so the + # reboot may report either form + reboot_pattern = re.compile(r"(No clients|none authenticated); rebooting") def check_output(line: str) -> None: """Check output for reboot message."""