From be2c859df3f3a5b237b9283f564b190d862c9767 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Oct 2025 08:01:47 -1000 Subject: [PATCH 1/9] [web_server] Consolidate duplicate client connection checks (saves 288 bytes of flash) (#11116) --- .../components/web_server/list_entities.cpp | 40 ----------------- esphome/components/web_server/web_server.cpp | 43 +++---------------- .../web_server_idf/web_server_idf.cpp | 3 ++ 3 files changed, 10 insertions(+), 76 deletions(-) diff --git a/esphome/components/web_server/list_entities.cpp b/esphome/components/web_server/list_entities.cpp index 3eb3764857..6b27545549 100644 --- a/esphome/components/web_server/list_entities.cpp +++ b/esphome/components/web_server/list_entities.cpp @@ -19,72 +19,54 @@ ListEntitiesIterator::~ListEntitiesIterator() {} #ifdef USE_BINARY_SENSOR bool ListEntitiesIterator::on_binary_sensor(binary_sensor::BinarySensor *obj) { - if (this->events_->count() == 0) - return true; this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::binary_sensor_all_json_generator); return true; } #endif #ifdef USE_COVER bool ListEntitiesIterator::on_cover(cover::Cover *obj) { - if (this->events_->count() == 0) - return true; this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::cover_all_json_generator); return true; } #endif #ifdef USE_FAN bool ListEntitiesIterator::on_fan(fan::Fan *obj) { - if (this->events_->count() == 0) - return true; this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::fan_all_json_generator); return true; } #endif #ifdef USE_LIGHT bool ListEntitiesIterator::on_light(light::LightState *obj) { - if (this->events_->count() == 0) - return true; this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::light_all_json_generator); return true; } #endif #ifdef USE_SENSOR bool ListEntitiesIterator::on_sensor(sensor::Sensor *obj) { - if (this->events_->count() == 0) - return true; this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::sensor_all_json_generator); return true; } #endif #ifdef USE_SWITCH bool ListEntitiesIterator::on_switch(switch_::Switch *obj) { - if (this->events_->count() == 0) - return true; this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::switch_all_json_generator); return true; } #endif #ifdef USE_BUTTON bool ListEntitiesIterator::on_button(button::Button *obj) { - if (this->events_->count() == 0) - return true; this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::button_all_json_generator); return true; } #endif #ifdef USE_TEXT_SENSOR bool ListEntitiesIterator::on_text_sensor(text_sensor::TextSensor *obj) { - if (this->events_->count() == 0) - return true; this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::text_sensor_all_json_generator); return true; } #endif #ifdef USE_LOCK bool ListEntitiesIterator::on_lock(lock::Lock *obj) { - if (this->events_->count() == 0) - return true; this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::lock_all_json_generator); return true; } @@ -92,8 +74,6 @@ bool ListEntitiesIterator::on_lock(lock::Lock *obj) { #ifdef USE_VALVE bool ListEntitiesIterator::on_valve(valve::Valve *obj) { - if (this->events_->count() == 0) - return true; this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::valve_all_json_generator); return true; } @@ -101,8 +81,6 @@ bool ListEntitiesIterator::on_valve(valve::Valve *obj) { #ifdef USE_CLIMATE bool ListEntitiesIterator::on_climate(climate::Climate *obj) { - if (this->events_->count() == 0) - return true; this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::climate_all_json_generator); return true; } @@ -110,8 +88,6 @@ bool ListEntitiesIterator::on_climate(climate::Climate *obj) { #ifdef USE_NUMBER bool ListEntitiesIterator::on_number(number::Number *obj) { - if (this->events_->count() == 0) - return true; this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::number_all_json_generator); return true; } @@ -119,8 +95,6 @@ bool ListEntitiesIterator::on_number(number::Number *obj) { #ifdef USE_DATETIME_DATE bool ListEntitiesIterator::on_date(datetime::DateEntity *obj) { - if (this->events_->count() == 0) - return true; this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::date_all_json_generator); return true; } @@ -128,8 +102,6 @@ bool ListEntitiesIterator::on_date(datetime::DateEntity *obj) { #ifdef USE_DATETIME_TIME bool ListEntitiesIterator::on_time(datetime::TimeEntity *obj) { - if (this->events_->count() == 0) - return true; this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::time_all_json_generator); return true; } @@ -137,8 +109,6 @@ bool ListEntitiesIterator::on_time(datetime::TimeEntity *obj) { #ifdef USE_DATETIME_DATETIME bool ListEntitiesIterator::on_datetime(datetime::DateTimeEntity *obj) { - if (this->events_->count() == 0) - return true; this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::datetime_all_json_generator); return true; } @@ -146,8 +116,6 @@ bool ListEntitiesIterator::on_datetime(datetime::DateTimeEntity *obj) { #ifdef USE_TEXT bool ListEntitiesIterator::on_text(text::Text *obj) { - if (this->events_->count() == 0) - return true; this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::text_all_json_generator); return true; } @@ -155,8 +123,6 @@ bool ListEntitiesIterator::on_text(text::Text *obj) { #ifdef USE_SELECT bool ListEntitiesIterator::on_select(select::Select *obj) { - if (this->events_->count() == 0) - return true; this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::select_all_json_generator); return true; } @@ -164,8 +130,6 @@ bool ListEntitiesIterator::on_select(select::Select *obj) { #ifdef USE_ALARM_CONTROL_PANEL bool ListEntitiesIterator::on_alarm_control_panel(alarm_control_panel::AlarmControlPanel *obj) { - if (this->events_->count() == 0) - return true; this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::alarm_control_panel_all_json_generator); return true; } @@ -173,8 +137,6 @@ bool ListEntitiesIterator::on_alarm_control_panel(alarm_control_panel::AlarmCont #ifdef USE_EVENT bool ListEntitiesIterator::on_event(event::Event *obj) { - if (this->events_->count() == 0) - return true; // Null event type, since we are just iterating over entities this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::event_all_json_generator); return true; @@ -183,8 +145,6 @@ bool ListEntitiesIterator::on_event(event::Event *obj) { #ifdef USE_UPDATE bool ListEntitiesIterator::on_update(update::UpdateEntity *obj) { - if (this->events_->count() == 0) - return true; this->events_->deferrable_send_state(obj, "state_detail_all", WebServer::update_all_json_generator); return true; } diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index cfd5fc947b..6f554ac958 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -152,6 +152,10 @@ void DeferredUpdateEventSource::loop() { void DeferredUpdateEventSource::deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator) { + // Skip if no connected clients to avoid unnecessary deferred queue processing + if (this->count() == 0) + return; + // allow all json "details_all" to go through before publishing bare state events, this avoids unnamed entries showing // up in the web GUI and reduces event load during initial connect if (!entities_iterator_.completed() && 0 != strcmp(event_type, "state_detail_all")) @@ -197,6 +201,9 @@ void DeferredUpdateEventSourceList::loop() { void DeferredUpdateEventSourceList::deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator) { + // Skip if no event sources (no connected clients) to avoid unnecessary iteration + if (this->empty()) + return; for (DeferredUpdateEventSource *dues : *this) { dues->deferrable_send_state(source, event_type, message_generator); } @@ -424,8 +431,6 @@ static JsonDetail get_request_detail(AsyncWebServerRequest *request) { #ifdef USE_SENSOR void WebServer::on_sensor_update(sensor::Sensor *obj, float state) { - if (this->events_.empty()) - return; this->events_.deferrable_send_state(obj, "state", sensor_state_json_generator); } void WebServer::handle_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) { @@ -473,8 +478,6 @@ std::string WebServer::sensor_json(sensor::Sensor *obj, float value, JsonDetail #ifdef USE_TEXT_SENSOR void WebServer::on_text_sensor_update(text_sensor::TextSensor *obj, const std::string &state) { - if (this->events_.empty()) - return; this->events_.deferrable_send_state(obj, "state", text_sensor_state_json_generator); } void WebServer::handle_text_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) { @@ -514,8 +517,6 @@ std::string WebServer::text_sensor_json(text_sensor::TextSensor *obj, const std: #ifdef USE_SWITCH void WebServer::on_switch_update(switch_::Switch *obj, bool state) { - if (this->events_.empty()) - return; this->events_.deferrable_send_state(obj, "state", switch_state_json_generator); } void WebServer::handle_switch_request(AsyncWebServerRequest *request, const UrlMatch &match) { @@ -627,8 +628,6 @@ std::string WebServer::button_json(button::Button *obj, JsonDetail start_config) #ifdef USE_BINARY_SENSOR void WebServer::on_binary_sensor_update(binary_sensor::BinarySensor *obj) { - if (this->events_.empty()) - return; this->events_.deferrable_send_state(obj, "state", binary_sensor_state_json_generator); } void WebServer::handle_binary_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) { @@ -667,8 +666,6 @@ std::string WebServer::binary_sensor_json(binary_sensor::BinarySensor *obj, bool #ifdef USE_FAN void WebServer::on_fan_update(fan::Fan *obj) { - if (this->events_.empty()) - return; this->events_.deferrable_send_state(obj, "state", fan_state_json_generator); } void WebServer::handle_fan_request(AsyncWebServerRequest *request, const UrlMatch &match) { @@ -743,8 +740,6 @@ std::string WebServer::fan_json(fan::Fan *obj, JsonDetail start_config) { #ifdef USE_LIGHT void WebServer::on_light_update(light::LightState *obj) { - if (this->events_.empty()) - return; this->events_.deferrable_send_state(obj, "state", light_state_json_generator); } void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMatch &match) { @@ -819,8 +814,6 @@ std::string WebServer::light_json(light::LightState *obj, JsonDetail start_confi #ifdef USE_COVER void WebServer::on_cover_update(cover::Cover *obj) { - if (this->events_.empty()) - return; this->events_.deferrable_send_state(obj, "state", cover_state_json_generator); } void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMatch &match) { @@ -906,8 +899,6 @@ std::string WebServer::cover_json(cover::Cover *obj, JsonDetail start_config) { #ifdef USE_NUMBER void WebServer::on_number_update(number::Number *obj, float state) { - if (this->events_.empty()) - return; this->events_.deferrable_send_state(obj, "state", number_state_json_generator); } void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlMatch &match) { @@ -975,8 +966,6 @@ std::string WebServer::number_json(number::Number *obj, float value, JsonDetail #ifdef USE_DATETIME_DATE void WebServer::on_date_update(datetime::DateEntity *obj) { - if (this->events_.empty()) - return; this->events_.deferrable_send_state(obj, "state", date_state_json_generator); } void WebServer::handle_date_request(AsyncWebServerRequest *request, const UrlMatch &match) { @@ -1034,8 +1023,6 @@ std::string WebServer::date_json(datetime::DateEntity *obj, JsonDetail start_con #ifdef USE_DATETIME_TIME void WebServer::on_time_update(datetime::TimeEntity *obj) { - if (this->events_.empty()) - return; this->events_.deferrable_send_state(obj, "state", time_state_json_generator); } void WebServer::handle_time_request(AsyncWebServerRequest *request, const UrlMatch &match) { @@ -1092,8 +1079,6 @@ std::string WebServer::time_json(datetime::TimeEntity *obj, JsonDetail start_con #ifdef USE_DATETIME_DATETIME void WebServer::on_datetime_update(datetime::DateTimeEntity *obj) { - if (this->events_.empty()) - return; this->events_.deferrable_send_state(obj, "state", datetime_state_json_generator); } void WebServer::handle_datetime_request(AsyncWebServerRequest *request, const UrlMatch &match) { @@ -1151,8 +1136,6 @@ std::string WebServer::datetime_json(datetime::DateTimeEntity *obj, JsonDetail s #ifdef USE_TEXT void WebServer::on_text_update(text::Text *obj, const std::string &state) { - if (this->events_.empty()) - return; this->events_.deferrable_send_state(obj, "state", text_state_json_generator); } void WebServer::handle_text_request(AsyncWebServerRequest *request, const UrlMatch &match) { @@ -1212,8 +1195,6 @@ std::string WebServer::text_json(text::Text *obj, const std::string &value, Json #ifdef USE_SELECT void WebServer::on_select_update(select::Select *obj, const std::string &state, size_t index) { - if (this->events_.empty()) - return; this->events_.deferrable_send_state(obj, "state", select_state_json_generator); } void WebServer::handle_select_request(AsyncWebServerRequest *request, const UrlMatch &match) { @@ -1270,8 +1251,6 @@ std::string WebServer::select_json(select::Select *obj, const std::string &value #ifdef USE_CLIMATE void WebServer::on_climate_update(climate::Climate *obj) { - if (this->events_.empty()) - return; this->events_.deferrable_send_state(obj, "state", climate_state_json_generator); } void WebServer::handle_climate_request(AsyncWebServerRequest *request, const UrlMatch &match) { @@ -1412,8 +1391,6 @@ std::string WebServer::climate_json(climate::Climate *obj, JsonDetail start_conf #ifdef USE_LOCK void WebServer::on_lock_update(lock::Lock *obj) { - if (this->events_.empty()) - return; this->events_.deferrable_send_state(obj, "state", lock_state_json_generator); } void WebServer::handle_lock_request(AsyncWebServerRequest *request, const UrlMatch &match) { @@ -1485,8 +1462,6 @@ std::string WebServer::lock_json(lock::Lock *obj, lock::LockState value, JsonDet #ifdef USE_VALVE void WebServer::on_valve_update(valve::Valve *obj) { - if (this->events_.empty()) - return; this->events_.deferrable_send_state(obj, "state", valve_state_json_generator); } void WebServer::handle_valve_request(AsyncWebServerRequest *request, const UrlMatch &match) { @@ -1568,8 +1543,6 @@ std::string WebServer::valve_json(valve::Valve *obj, JsonDetail start_config) { #ifdef USE_ALARM_CONTROL_PANEL void WebServer::on_alarm_control_panel_update(alarm_control_panel::AlarmControlPanel *obj) { - if (this->events_.empty()) - return; this->events_.deferrable_send_state(obj, "state", alarm_control_panel_state_json_generator); } void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *request, const UrlMatch &match) { @@ -1714,8 +1687,6 @@ static const char *update_state_to_string(update::UpdateState state) { } void WebServer::on_update(update::UpdateEntity *obj) { - if (this->events_.empty()) - return; this->events_.deferrable_send_state(obj, "state", update_state_json_generator); } void WebServer::handle_update_request(AsyncWebServerRequest *request, const UrlMatch &match) { diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index b38c5fb92a..d90efd18bc 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -412,6 +412,9 @@ void AsyncEventSource::try_send_nodefer(const char *message, const char *event, void AsyncEventSource::deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator) { + // Skip if no connected clients to avoid unnecessary processing + if (this->empty()) + return; for (auto *ses : this->sessions_) { if (ses->fd_.load() != 0) { // Skip dead sessions ses->deferrable_send_state(source, event_type, message_generator); From bcc424afed08e1c2658c0d89662c25c2c8e504ed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Oct 2025 08:21:19 -1000 Subject: [PATCH 2/9] [web_server] Reduce code duplication in JSON generation with helper functions (#11117) --- esphome/components/web_server/web_server.cpp | 54 ++++++-------------- 1 file changed, 17 insertions(+), 37 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 6f554ac958..f18f21b16b 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -458,13 +458,8 @@ std::string WebServer::sensor_json(sensor::Sensor *obj, float value, JsonDetail const auto uom_ref = obj->get_unit_of_measurement_ref(); - // Build JSON directly inline - std::string state; - if (std::isnan(value)) { - state = "NA"; - } else { - state = value_accuracy_with_uom_to_string(value, obj->get_accuracy_decimals(), uom_ref); - } + std::string state = + std::isnan(value) ? "NA" : value_accuracy_with_uom_to_string(value, obj->get_accuracy_decimals(), uom_ref); set_json_icon_state_value(root, obj, "sensor", state, value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); @@ -795,8 +790,7 @@ std::string WebServer::light_json(light::LightState *obj, JsonDetail start_confi json::JsonBuilder builder; JsonObject root = builder.root(); - set_json_id(root, obj, "light", start_config); - root["state"] = obj->remote_values.is_on() ? "ON" : "OFF"; + set_json_value(root, obj, "light", obj->remote_values.is_on() ? "ON" : "OFF", start_config); light::LightJSONSchema::dump_json(*obj, root); if (start_config == DETAIL_ALL) { @@ -939,7 +933,13 @@ std::string WebServer::number_json(number::Number *obj, float value, JsonDetail const auto uom_ref = obj->traits.get_unit_of_measurement_ref(); - set_json_id(root, obj, "number", start_config); + std::string val_str = std::isnan(value) + ? "\"NaN\"" + : value_accuracy_to_string(value, step_to_accuracy_decimals(obj->traits.get_step())); + std::string state_str = std::isnan(value) ? "NA" + : value_accuracy_with_uom_to_string( + value, step_to_accuracy_decimals(obj->traits.get_step()), uom_ref); + set_json_icon_state_value(root, obj, "number", state_str, val_str, start_config); if (start_config == DETAIL_ALL) { root["min_value"] = value_accuracy_to_string(obj->traits.get_min_value(), step_to_accuracy_decimals(obj->traits.get_step())); @@ -951,14 +951,6 @@ std::string WebServer::number_json(number::Number *obj, float value, JsonDetail root["uom"] = uom_ref; this->add_sorting_info_(root, obj); } - if (std::isnan(value)) { - root["value"] = "\"NaN\""; - root["state"] = "NA"; - } else { - root["value"] = value_accuracy_to_string(value, step_to_accuracy_decimals(obj->traits.get_step())); - root["state"] = - value_accuracy_with_uom_to_string(value, step_to_accuracy_decimals(obj->traits.get_step()), uom_ref); - } return builder.serialize(); } @@ -1009,10 +1001,8 @@ std::string WebServer::date_json(datetime::DateEntity *obj, JsonDetail start_con json::JsonBuilder builder; JsonObject root = builder.root(); - set_json_id(root, obj, "date", start_config); std::string value = str_sprintf("%d-%02d-%02d", obj->year, obj->month, obj->day); - root["value"] = value; - root["state"] = value; + set_json_icon_state_value(root, obj, "date", value, value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1065,10 +1055,8 @@ std::string WebServer::time_json(datetime::TimeEntity *obj, JsonDetail start_con json::JsonBuilder builder; JsonObject root = builder.root(); - set_json_id(root, obj, "time", start_config); std::string value = str_sprintf("%02d:%02d:%02d", obj->hour, obj->minute, obj->second); - root["value"] = value; - root["state"] = value; + set_json_icon_state_value(root, obj, "time", value, value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1121,11 +1109,9 @@ std::string WebServer::datetime_json(datetime::DateTimeEntity *obj, JsonDetail s json::JsonBuilder builder; JsonObject root = builder.root(); - set_json_id(root, obj, "datetime", start_config); std::string value = str_sprintf("%d-%02d-%02d %02d:%02d:%02d", obj->year, obj->month, obj->day, obj->hour, obj->minute, obj->second); - root["value"] = value; - root["state"] = value; + set_json_icon_state_value(root, obj, "datetime", value, value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1174,16 +1160,11 @@ std::string WebServer::text_json(text::Text *obj, const std::string &value, Json json::JsonBuilder builder; JsonObject root = builder.root(); - set_json_id(root, obj, "text", start_config); + std::string state = obj->traits.get_mode() == text::TextMode::TEXT_MODE_PASSWORD ? "********" : value; + set_json_icon_state_value(root, obj, "text", state, value, start_config); root["min_length"] = obj->traits.get_min_length(); root["max_length"] = obj->traits.get_max_length(); root["pattern"] = obj->traits.get_pattern(); - if (obj->traits.get_mode() == text::TextMode::TEXT_MODE_PASSWORD) { - root["state"] = "********"; - } else { - root["state"] = value; - } - root["value"] = value; if (start_config == DETAIL_ALL) { root["mode"] = (int) obj->traits.get_mode(); this->add_sorting_info_(root, obj); @@ -1725,9 +1706,8 @@ std::string WebServer::update_json(update::UpdateEntity *obj, JsonDetail start_c json::JsonBuilder builder; JsonObject root = builder.root(); - set_json_id(root, obj, "update", start_config); - root["value"] = obj->update_info.latest_version; - root["state"] = update_state_to_string(obj->state); + set_json_icon_state_value(root, obj, "update", update_state_to_string(obj->state), obj->update_info.latest_version, + start_config); if (start_config == DETAIL_ALL) { root["current_version"] = obj->update_info.current_version; root["title"] = obj->update_info.title; From c10f68ef0cc9e68eb6a4f1f1eff3cab6766281cf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Oct 2025 08:24:57 -1000 Subject: [PATCH 3/9] [mdns] Conditionally store services to reduce RAM usage by 200-464 bytes (#11180) --- esphome/components/mdns/__init__.py | 19 ++++++++++++++++++- esphome/components/mdns/mdns_component.cpp | 17 +++++++++++------ esphome/components/mdns/mdns_component.h | 6 +++++- esphome/components/mdns/mdns_esp32.cpp | 5 +++-- esphome/components/mdns/mdns_esp8266.cpp | 5 +++-- esphome/components/mdns/mdns_host.cpp | 4 +++- esphome/components/mdns/mdns_libretiny.cpp | 5 +++-- esphome/components/mdns/mdns_rp2040.cpp | 5 +++-- esphome/components/openthread/__init__.py | 5 ++++- esphome/core/defines.h | 1 + 10 files changed, 54 insertions(+), 18 deletions(-) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 14e0420ef5..c6a9ee1a0c 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -1,6 +1,6 @@ import esphome.codegen as cg from esphome.components.esp32 import add_idf_component -from esphome.config_helpers import filter_source_files_from_platform +from esphome.config_helpers import filter_source_files_from_platform, get_logger_level import esphome.config_validation as cv from esphome.const import ( CONF_DISABLED, @@ -125,6 +125,17 @@ def mdns_service( ) +def enable_mdns_storage(): + """Enable persistent storage of mDNS services in the MDNSComponent. + + Called by external components (like OpenThread) that need access to + services after setup() completes via get_services(). + + Public API for external components. Do not remove. + """ + cg.add_define("USE_MDNS_STORE_SERVICES") + + @coroutine_with_priority(CoroPriority.NETWORK_SERVICES) async def to_code(config): if config[CONF_DISABLED] is True: @@ -150,6 +161,8 @@ async def to_code(config): if config[CONF_SERVICES]: cg.add_define("USE_MDNS_EXTRA_SERVICES") + # Extra services need to be stored persistently + enable_mdns_storage() # Ensure at least 1 service (fallback service) cg.add_define("MDNS_SERVICE_COUNT", max(1, service_count)) @@ -171,6 +184,10 @@ async def to_code(config): # Ensure at least 1 to avoid zero-size array cg.add_define("MDNS_DYNAMIC_TXT_COUNT", max(1, dynamic_txt_count)) + # Enable storage if verbose logging is enabled (for dump_config) + if get_logger_level() in ("VERBOSE", "VERY_VERBOSE"): + enable_mdns_storage() + var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 9cb664c3c3..fea3ced99f 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -36,7 +36,7 @@ MDNS_STATIC_CONST_CHAR(SERVICE_TCP, "_tcp"); // Wrap build-time defines into flash storage MDNS_STATIC_CONST_CHAR(VALUE_VERSION, ESPHOME_VERSION); -void MDNSComponent::compile_records_() { +void MDNSComponent::compile_records_(StaticVector &services) { this->hostname_ = App.get_name(); // IMPORTANT: The #ifdef blocks below must match COMPONENTS_WITH_MDNS_SERVICES @@ -53,7 +53,7 @@ void MDNSComponent::compile_records_() { MDNS_STATIC_CONST_CHAR(VALUE_BOARD, ESPHOME_BOARD); if (api::global_api_server != nullptr) { - auto &service = this->services_.emplace_next(); + auto &service = services.emplace_next(); service.service_type = MDNS_STR(SERVICE_ESPHOMELIB); service.proto = MDNS_STR(SERVICE_TCP); service.port = api::global_api_server->get_port(); @@ -146,7 +146,7 @@ void MDNSComponent::compile_records_() { #ifdef USE_PROMETHEUS MDNS_STATIC_CONST_CHAR(SERVICE_PROMETHEUS, "_prometheus-http"); - auto &prom_service = this->services_.emplace_next(); + auto &prom_service = services.emplace_next(); prom_service.service_type = MDNS_STR(SERVICE_PROMETHEUS); prom_service.proto = MDNS_STR(SERVICE_TCP); prom_service.port = USE_WEBSERVER_PORT; @@ -155,7 +155,7 @@ void MDNSComponent::compile_records_() { #ifdef USE_WEBSERVER MDNS_STATIC_CONST_CHAR(SERVICE_HTTP, "_http"); - auto &web_service = this->services_.emplace_next(); + auto &web_service = services.emplace_next(); web_service.service_type = MDNS_STR(SERVICE_HTTP); web_service.proto = MDNS_STR(SERVICE_TCP); web_service.port = USE_WEBSERVER_PORT; @@ -167,12 +167,17 @@ void MDNSComponent::compile_records_() { // Publish "http" service if not using native API or any other services // This is just to have *some* mDNS service so that .local resolution works - auto &fallback_service = this->services_.emplace_next(); + auto &fallback_service = services.emplace_next(); fallback_service.service_type = MDNS_STR(SERVICE_HTTP); fallback_service.proto = MDNS_STR(SERVICE_TCP); fallback_service.port = USE_WEBSERVER_PORT; fallback_service.txt_records.push_back({MDNS_STR(TXT_VERSION), MDNS_STR(VALUE_VERSION)}); #endif + +#ifdef USE_MDNS_STORE_SERVICES + // Copy to member variable if storage is enabled (verbose logging, OpenThread, or extra services) + this->services_ = services; +#endif } void MDNSComponent::dump_config() { @@ -180,7 +185,7 @@ void MDNSComponent::dump_config() { "mDNS:\n" " Hostname: %s", this->hostname_.c_str()); -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE +#ifdef USE_MDNS_STORE_SERVICES ESP_LOGV(TAG, " Services:"); for (const auto &service : this->services_) { ESP_LOGV(TAG, " - %s, %s, %d", MDNS_STR_ARG(service.service_type), MDNS_STR_ARG(service.proto), diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 141e42d976..62476e9504 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -55,7 +55,9 @@ class MDNSComponent : public Component { void add_extra_service(MDNSService service) { this->services_.emplace_next() = std::move(service); } #endif +#ifdef USE_MDNS_STORE_SERVICES const StaticVector &get_services() const { return this->services_; } +#endif void on_shutdown() override; @@ -71,9 +73,11 @@ class MDNSComponent : public Component { StaticVector dynamic_txt_values_; protected: +#ifdef USE_MDNS_STORE_SERVICES StaticVector services_{}; +#endif std::string hostname_; - void compile_records_(); + void compile_records_(StaticVector &services); }; } // namespace mdns diff --git a/esphome/components/mdns/mdns_esp32.cpp b/esphome/components/mdns/mdns_esp32.cpp index e77c0b9b05..da47be7dbc 100644 --- a/esphome/components/mdns/mdns_esp32.cpp +++ b/esphome/components/mdns/mdns_esp32.cpp @@ -12,7 +12,8 @@ namespace mdns { static const char *const TAG = "mdns"; void MDNSComponent::setup() { - this->compile_records_(); + StaticVector services; + this->compile_records_(services); esp_err_t err = mdns_init(); if (err != ESP_OK) { @@ -24,7 +25,7 @@ void MDNSComponent::setup() { mdns_hostname_set(this->hostname_.c_str()); mdns_instance_name_set(this->hostname_.c_str()); - for (const auto &service : this->services_) { + for (const auto &service : services) { std::vector txt_records; for (const auto &record : service.txt_records) { mdns_txt_item_t it{}; diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index f3779042ed..06503742db 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -12,11 +12,12 @@ namespace esphome { namespace mdns { void MDNSComponent::setup() { - this->compile_records_(); + StaticVector services; + this->compile_records_(services); MDNS.begin(this->hostname_.c_str()); - for (const auto &service : this->services_) { + for (const auto &service : services) { // Strip the leading underscore from the proto and service_type. While it is // part of the wire protocol to have an underscore, and for example ESP-IDF // expects the underscore to be there, the ESP8266 implementation always adds diff --git a/esphome/components/mdns/mdns_host.cpp b/esphome/components/mdns/mdns_host.cpp index 78767ed136..f645d8d068 100644 --- a/esphome/components/mdns/mdns_host.cpp +++ b/esphome/components/mdns/mdns_host.cpp @@ -9,7 +9,9 @@ namespace esphome { namespace mdns { -void MDNSComponent::setup() { this->compile_records_(); } +void MDNSComponent::setup() { + // Host platform doesn't have actual mDNS implementation +} void MDNSComponent::on_shutdown() {} diff --git a/esphome/components/mdns/mdns_libretiny.cpp b/esphome/components/mdns/mdns_libretiny.cpp index 5540bf361a..a959482ff6 100644 --- a/esphome/components/mdns/mdns_libretiny.cpp +++ b/esphome/components/mdns/mdns_libretiny.cpp @@ -12,11 +12,12 @@ namespace esphome { namespace mdns { void MDNSComponent::setup() { - this->compile_records_(); + StaticVector services; + this->compile_records_(services); MDNS.begin(this->hostname_.c_str()); - for (const auto &service : this->services_) { + for (const auto &service : services) { // Strip the leading underscore from the proto and service_type. While it is // part of the wire protocol to have an underscore, and for example ESP-IDF // expects the underscore to be there, the ESP8266 implementation always adds diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index 5ad006f5d4..9dfb05bda9 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -12,11 +12,12 @@ namespace esphome { namespace mdns { void MDNSComponent::setup() { - this->compile_records_(); + StaticVector services; + this->compile_records_(services); MDNS.begin(this->hostname_.c_str()); - for (const auto &service : this->services_) { + for (const auto &service : services) { // Strip the leading underscore from the proto and service_type. While it is // part of the wire protocol to have an underscore, and for example ESP-IDF // expects the underscore to be there, the ESP8266 implementation always adds diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index 2f085ebaae..3fac497c3d 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -5,7 +5,7 @@ from esphome.components.esp32 import ( add_idf_sdkconfig_option, only_on_variant, ) -from esphome.components.mdns import MDNSComponent +from esphome.components.mdns import MDNSComponent, enable_mdns_storage import esphome.config_validation as cv from esphome.const import CONF_CHANNEL, CONF_ENABLE_IPV6, CONF_ID import esphome.final_validate as fv @@ -141,6 +141,9 @@ FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config): cg.add_define("USE_OPENTHREAD") + # OpenThread SRP needs access to mDNS services after setup + enable_mdns_storage() + ot = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(ot, config) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index ae44e16624..ed30efd0b5 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -83,6 +83,7 @@ #define USE_LVGL_TILEVIEW #define USE_LVGL_TOUCHSCREEN #define USE_MDNS +#define USE_MDNS_STORE_SERVICES #define MDNS_SERVICE_COUNT 3 #define MDNS_DYNAMIC_TXT_COUNT 3 #define USE_MEDIA_PLAYER From aec60d122b7e1e0c7fed4a4d25a07c790e49fe03 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Oct 2025 09:34:12 -1000 Subject: [PATCH 4/9] Bump esphome-dashboard from 20251009.0 to 20251013.0 (#11212) 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 64946263ea..9937709c1c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,7 @@ pyserial==3.5 platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.1.0 click==8.1.7 -esphome-dashboard==20251009.0 +esphome-dashboard==20251013.0 aioesphomeapi==41.14.0 zeroconf==0.148.0 puremagic==1.30 From 0f356fcc79dfe30b4a4dd71b0221906274fbf3a3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Oct 2025 10:20:43 -1000 Subject: [PATCH 5/9] [core] Optimize looping_components_ with FixedVector to save flash (#11183) --- esphome/core/application.cpp | 4 +-- esphome/core/application.h | 2 +- esphome/core/helpers.h | 48 ++++++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 1be193bb7e..c745aa0ae5 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -340,8 +340,8 @@ void Application::calculate_looping_components_() { } } - // Pre-reserve vector to avoid reallocations - this->looping_components_.reserve(total_looping); + // Initialize FixedVector with exact size - no reallocation possible + this->looping_components_.init(total_looping); // Add all components with loop override that aren't already LOOP_DONE // Some components (like logger) may call disable_loop() during initialization diff --git a/esphome/core/application.h b/esphome/core/application.h index 1f22499051..b0f9c23191 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -472,7 +472,7 @@ class Application { // - When a component is enabled, it's swapped with the first inactive component // and active_end_ is incremented // - This eliminates branch mispredictions from flag checking in the hot loop - std::vector looping_components_{}; + FixedVector looping_components_{}; #ifdef USE_SOCKET_SELECT_SUPPORT std::vector socket_fds_; // Vector of all monitored socket file descriptors #endif diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index e06f2d15ef..b5a0a1c8ac 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -159,6 +159,54 @@ template class StaticVector { const_reverse_iterator rend() const { return const_reverse_iterator(begin()); } }; +/// Fixed-capacity vector - allocates once at runtime, never reallocates +/// This avoids std::vector template overhead (_M_realloc_insert, _M_default_append) +/// when size is known at initialization but not at compile time +template class FixedVector { + private: + T *data_{nullptr}; + size_t size_{0}; + size_t capacity_{0}; + + public: + FixedVector() = default; + + ~FixedVector() { + if (data_ != nullptr) { + delete[] data_; + } + } + + // Disable copy to avoid accidental copies + FixedVector(const FixedVector &) = delete; + FixedVector &operator=(const FixedVector &) = delete; + + // Allocate capacity - can only be called once on empty vector + void init(size_t n) { + if (data_ == nullptr && n > 0) { + data_ = new T[n]; + capacity_ = n; + size_ = 0; + } + } + + /// Add element without bounds checking + /// Caller must ensure sufficient capacity was allocated via init() + /// Silently ignores pushes beyond capacity (no exception or assertion) + void push_back(const T &value) { + if (size_ < capacity_) { + data_[size_++] = value; + } + } + + size_t size() const { return size_; } + + /// Access element without bounds checking (matches std::vector behavior) + /// Caller must ensure index is valid (i < size()) + T &operator[](size_t i) { return data_[i]; } + const T &operator[](size_t i) const { return data_[i]; } +}; + ///@} /// @name Mathematics From 8d8fcfeda2082a74b9b7c91dc7ab37ea78530aa2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Oct 2025 10:39:38 -1000 Subject: [PATCH 6/9] [core] Add make_name_with_suffix helper to optimize string concatenation (#11176) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/esp32_ble/ble.cpp | 7 ++++-- .../ethernet/ethernet_component.cpp | 4 +++- esphome/components/mqtt/mqtt_client.cpp | 3 ++- esphome/components/wifi/wifi_component.cpp | 4 +++- esphome/config_validation.py | 7 ++++++ esphome/core/application.h | 12 +++++++--- esphome/core/config.py | 2 +- esphome/core/helpers.cpp | 24 +++++++++++++++++++ esphome/core/helpers.h | 10 ++++++++ 9 files changed, 64 insertions(+), 9 deletions(-) diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 0c340c55cc..1f6bc6d0a0 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -213,8 +213,11 @@ bool ESP32BLE::ble_setup_() { if (this->name_.has_value()) { name = this->name_.value(); if (App.is_name_add_mac_suffix_enabled()) { - name += "-"; - name += get_mac_address().substr(6); + // MAC address suffix length (last 6 characters of 12-char MAC address string) + constexpr size_t mac_address_suffix_len = 6; + const std::string mac_addr = get_mac_address(); + const char *mac_suffix_ptr = mac_addr.c_str() + mac_address_suffix_len; + name = make_name_with_suffix(name, '-', mac_suffix_ptr, mac_address_suffix_len); } } else { name = App.get_name(); diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index 28043dd969..13adab8815 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -691,7 +691,9 @@ void EthernetComponent::set_manual_ip(const ManualIP &manual_ip) { this->manual_ std::string EthernetComponent::get_use_address() const { if (this->use_address_.empty()) { - return App.get_name() + ".local"; + // ".local" suffix length for mDNS hostnames + constexpr size_t mdns_local_suffix_len = 5; + return make_name_with_suffix(App.get_name(), '.', "local", mdns_local_suffix_len); } return this->use_address_; } diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index 7ab6efd1a1..16f54ab8a0 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -29,7 +29,8 @@ static const char *const TAG = "mqtt"; MQTTClientComponent::MQTTClientComponent() { global_mqtt_client = this; - this->credentials_.client_id = App.get_name() + "-" + get_mac_address(); + const std::string mac_addr = get_mac_address(); + this->credentials_.client_id = make_name_with_suffix(App.get_name(), '-', mac_addr.c_str(), mac_addr.size()); } // Connection diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 71ee4271ba..0f9f879181 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -267,7 +267,9 @@ network::IPAddress WiFiComponent::get_dns_address(int num) { } std::string WiFiComponent::get_use_address() const { if (this->use_address_.empty()) { - return App.get_name() + ".local"; + // ".local" suffix length for mDNS hostnames + constexpr size_t mdns_local_suffix_len = 5; + return make_name_with_suffix(App.get_name(), '.', "local", mdns_local_suffix_len); } return this->use_address_; } diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 7aaba886e3..ebfedf2017 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -1195,6 +1195,13 @@ def validate_bytes(value): def hostname(value): + """Validate that the value is a valid hostname. + + Maximum length is 63 characters per RFC 1035. + + Note: If this limit is changed, update MAX_NAME_WITH_SUFFIX_SIZE in + esphome/core/helpers.cpp to accommodate the new maximum length. + """ value = string(value) if re.match(r"^[a-z0-9-]{1,63}$", value, re.IGNORECASE) is not None: return value diff --git a/esphome/core/application.h b/esphome/core/application.h index b0f9c23191..6e7f1b49f2 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -102,9 +102,15 @@ class Application { arch_init(); this->name_add_mac_suffix_ = name_add_mac_suffix; if (name_add_mac_suffix) { - const std::string mac_suffix = get_mac_address().substr(6); - this->name_ = name + "-" + mac_suffix; - this->friendly_name_ = friendly_name.empty() ? "" : friendly_name + " " + mac_suffix; + // MAC address suffix length (last 6 characters of 12-char MAC address string) + constexpr size_t mac_address_suffix_len = 6; + const std::string mac_addr = get_mac_address(); + // Use pointer + offset to avoid substr() allocation + const char *mac_suffix_ptr = mac_addr.c_str() + mac_address_suffix_len; + this->name_ = make_name_with_suffix(name, '-', mac_suffix_ptr, mac_address_suffix_len); + if (!friendly_name.empty()) { + this->friendly_name_ = make_name_with_suffix(friendly_name, ' ', mac_suffix_ptr, mac_address_suffix_len); + } } else { this->name_ = name; this->friendly_name_ = friendly_name; diff --git a/esphome/core/config.py b/esphome/core/config.py index 7bf7f82a8b..8a5876dbcf 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -200,7 +200,7 @@ CONFIG_SCHEMA = cv.All( cv.Schema( { cv.Required(CONF_NAME): cv.valid_name, - cv.Optional(CONF_FRIENDLY_NAME, ""): cv.string, + cv.Optional(CONF_FRIENDLY_NAME, ""): cv.All(cv.string, cv.Length(max=120)), cv.Optional(CONF_AREA): validate_area_config, cv.Optional(CONF_COMMENT): cv.string, cv.Required(CONF_BUILD_PATH): cv.string, diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index d4f6809776..fb8b220b2f 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -235,6 +235,30 @@ std::string str_sprintf(const char *fmt, ...) { return str; } +// Maximum size for name with suffix: 120 (max friendly name) + 1 (separator) + 6 (MAC suffix) + 1 (null term) +static constexpr size_t MAX_NAME_WITH_SUFFIX_SIZE = 128; + +std::string make_name_with_suffix(const std::string &name, char sep, const char *suffix_ptr, size_t suffix_len) { + char buffer[MAX_NAME_WITH_SUFFIX_SIZE]; + size_t name_len = name.size(); + size_t total_len = name_len + 1 + suffix_len; + + // Silently truncate if needed: prioritize keeping the full suffix + if (total_len >= MAX_NAME_WITH_SUFFIX_SIZE) { + // NOTE: This calculation could underflow if suffix_len >= MAX_NAME_WITH_SUFFIX_SIZE - 2, + // but this is safe because this helper is only called with small suffixes: + // MAC suffixes (6-12 bytes), ".local" (5 bytes), etc. + name_len = MAX_NAME_WITH_SUFFIX_SIZE - suffix_len - 2; // -2 for separator and null terminator + total_len = name_len + 1 + suffix_len; + } + + memcpy(buffer, name.c_str(), name_len); + buffer[name_len] = sep; + memcpy(buffer + name_len + 1, suffix_ptr, suffix_len); + buffer[total_len] = '\0'; + return std::string(buffer, total_len); +} + // Parsing & formatting size_t parse_hex(const char *str, size_t length, uint8_t *data, size_t count) { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index b5a0a1c8ac..ed1d5ac108 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -354,6 +354,16 @@ std::string __attribute__((format(printf, 1, 3))) str_snprintf(const char *fmt, /// sprintf-like function returning std::string. std::string __attribute__((format(printf, 1, 2))) str_sprintf(const char *fmt, ...); +/// Concatenate a name with a separator and suffix using an efficient stack-based approach. +/// This avoids multiple heap allocations during string construction. +/// Maximum name length supported is 120 characters for friendly names. +/// @param name The base name string +/// @param sep The separator character (e.g., '-', ' ', or '.') +/// @param suffix_ptr Pointer to the suffix characters +/// @param suffix_len Length of the suffix +/// @return The concatenated string: name + sep + suffix +std::string make_name_with_suffix(const std::string &name, char sep, const char *suffix_ptr, size_t suffix_len); + ///@} /// @name Parsing & formatting From 6372099df3a9a2b4dfb5bff5916b5f40cd47edf5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Oct 2025 10:53:11 -1000 Subject: [PATCH 7/9] [http_request] Pass parameters by const reference to reduce flash usage (#11184) --- esphome/components/http_request/http_request.h | 4 ++-- esphome/components/http_request/http_request_arduino.cpp | 5 +++-- esphome/components/http_request/http_request_arduino.h | 4 ++-- esphome/components/http_request/http_request_host.cpp | 5 +++-- esphome/components/http_request/http_request_host.h | 4 ++-- esphome/components/http_request/http_request_idf.cpp | 5 +++-- esphome/components/http_request/http_request_idf.h | 4 ++-- 7 files changed, 17 insertions(+), 14 deletions(-) diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index 95515f731a..bb14cc6f51 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -167,8 +167,8 @@ class HttpRequestComponent : public Component { } protected: - virtual std::shared_ptr perform(std::string url, std::string method, std::string body, - std::list
request_headers, + virtual std::shared_ptr perform(const std::string &url, const std::string &method, + const std::string &body, const std::list
&request_headers, std::set collect_headers) = 0; const char *useragent_{nullptr}; bool follow_redirects_{}; diff --git a/esphome/components/http_request/http_request_arduino.cpp b/esphome/components/http_request/http_request_arduino.cpp index c009b33c2d..dfdbbd3fab 100644 --- a/esphome/components/http_request/http_request_arduino.cpp +++ b/esphome/components/http_request/http_request_arduino.cpp @@ -14,8 +14,9 @@ namespace http_request { static const char *const TAG = "http_request.arduino"; -std::shared_ptr HttpRequestArduino::perform(std::string url, std::string method, std::string body, - std::list
request_headers, +std::shared_ptr HttpRequestArduino::perform(const std::string &url, const std::string &method, + const std::string &body, + const std::list
&request_headers, std::set collect_headers) { if (!network::is_connected()) { this->status_momentary_error("failed", 1000); diff --git a/esphome/components/http_request/http_request_arduino.h b/esphome/components/http_request/http_request_arduino.h index 44744f8c78..c8208c74d8 100644 --- a/esphome/components/http_request/http_request_arduino.h +++ b/esphome/components/http_request/http_request_arduino.h @@ -31,8 +31,8 @@ class HttpContainerArduino : public HttpContainer { class HttpRequestArduino : public HttpRequestComponent { protected: - std::shared_ptr perform(std::string url, std::string method, std::string body, - std::list
request_headers, + std::shared_ptr perform(const std::string &url, const std::string &method, const std::string &body, + const std::list
&request_headers, std::set collect_headers) override; }; diff --git a/esphome/components/http_request/http_request_host.cpp b/esphome/components/http_request/http_request_host.cpp index 0b4c998a40..c20ea552b7 100644 --- a/esphome/components/http_request/http_request_host.cpp +++ b/esphome/components/http_request/http_request_host.cpp @@ -17,8 +17,9 @@ namespace http_request { static const char *const TAG = "http_request.host"; -std::shared_ptr HttpRequestHost::perform(std::string url, std::string method, std::string body, - std::list
request_headers, +std::shared_ptr HttpRequestHost::perform(const std::string &url, const std::string &method, + const std::string &body, + const std::list
&request_headers, std::set response_headers) { if (!network::is_connected()) { this->status_momentary_error("failed", 1000); diff --git a/esphome/components/http_request/http_request_host.h b/esphome/components/http_request/http_request_host.h index bbeed87f70..fdd72e7ea5 100644 --- a/esphome/components/http_request/http_request_host.h +++ b/esphome/components/http_request/http_request_host.h @@ -18,8 +18,8 @@ class HttpContainerHost : public HttpContainer { class HttpRequestHost : public HttpRequestComponent { public: - std::shared_ptr perform(std::string url, std::string method, std::string body, - std::list
request_headers, + std::shared_ptr perform(const std::string &url, const std::string &method, const std::string &body, + const std::list
&request_headers, std::set response_headers) override; void set_ca_path(const char *ca_path) { this->ca_path_ = ca_path; } diff --git a/esphome/components/http_request/http_request_idf.cpp b/esphome/components/http_request/http_request_idf.cpp index 89a0891b03..a91c0bfc25 100644 --- a/esphome/components/http_request/http_request_idf.cpp +++ b/esphome/components/http_request/http_request_idf.cpp @@ -52,8 +52,9 @@ esp_err_t HttpRequestIDF::http_event_handler(esp_http_client_event_t *evt) { return ESP_OK; } -std::shared_ptr HttpRequestIDF::perform(std::string url, std::string method, std::string body, - std::list
request_headers, +std::shared_ptr HttpRequestIDF::perform(const std::string &url, const std::string &method, + const std::string &body, + const std::list
&request_headers, std::set collect_headers) { if (!network::is_connected()) { this->status_momentary_error("failed", 1000); diff --git a/esphome/components/http_request/http_request_idf.h b/esphome/components/http_request/http_request_idf.h index 5c5b784853..90dee0be68 100644 --- a/esphome/components/http_request/http_request_idf.h +++ b/esphome/components/http_request/http_request_idf.h @@ -37,8 +37,8 @@ class HttpRequestIDF : public HttpRequestComponent { void set_buffer_size_tx(uint16_t buffer_size_tx) { this->buffer_size_tx_ = buffer_size_tx; } protected: - std::shared_ptr perform(std::string url, std::string method, std::string body, - std::list
request_headers, + std::shared_ptr perform(const std::string &url, const std::string &method, const std::string &body, + const std::list
&request_headers, std::set collect_headers) override; // if zero ESP-IDF will use DEFAULT_HTTP_BUF_SIZE uint16_t buffer_size_rx_{}; From 3df4dbd3a62c73480c1f6e756081f37ee76717fe Mon Sep 17 00:00:00 2001 From: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> Date: Mon, 13 Oct 2025 17:12:45 -0400 Subject: [PATCH 8/9] [core] Properly clean the build dir in the HA addon (#11208) --- esphome/writer.py | 25 +++++++++++------- tests/unit_tests/test_writer.py | 46 +++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/esphome/writer.py b/esphome/writer.py index b5cfd9b667..8eee445cf1 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -15,6 +15,8 @@ from esphome.const import ( from esphome.core import CORE, EsphomeError from esphome.helpers import ( copy_file_if_changed, + get_str_env, + is_ha_addon, read_file, walk_files, write_file_if_changed, @@ -338,16 +340,21 @@ def clean_build(): def clean_all(configuration: list[str]): import shutil - # Clean entire build dir - for dir in configuration: - build_dir = Path(dir) / ".esphome" - if build_dir.is_dir(): - _LOGGER.info("Cleaning %s", build_dir) - # Don't remove storage as it will cause the dashboard to regenerate all configs - for item in build_dir.iterdir(): - if item.is_file(): + data_dirs = [Path(dir) / ".esphome" for dir in configuration] + if is_ha_addon(): + data_dirs.append(Path("/data")) + if "ESPHOME_DATA_DIR" in os.environ: + data_dirs.append(Path(get_str_env("ESPHOME_DATA_DIR", None))) + + # Clean build dir + for dir in data_dirs: + if dir.is_dir(): + _LOGGER.info("Cleaning %s", dir) + # Don't remove storage or .json files which are needed by the dashboard + for item in dir.iterdir(): + if item.is_file() and not item.name.endswith(".json"): item.unlink() - elif item.name != "storage" and item.is_dir(): + elif item.is_dir() and item.name != "storage": shutil.rmtree(item) # Clean PlatformIO project files diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index bffd2b3881..a4490fbbc0 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -985,3 +985,49 @@ def test_clean_all_removes_non_storage_directories( # Verify logging mentions cleaning assert "Cleaning" in caplog.text assert str(build_dir) in caplog.text + + +@patch("esphome.writer.CORE") +def test_clean_all_preserves_json_files( + mock_core: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test clean_all preserves .json files.""" + # Create build directory with various files + config_dir = tmp_path / "config" + config_dir.mkdir() + + build_dir = config_dir / ".esphome" + build_dir.mkdir() + + # Create .json files (should be preserved) + (build_dir / "config.json").write_text('{"config": "data"}') + (build_dir / "metadata.json").write_text('{"metadata": "info"}') + + # Create non-.json files (should be removed) + (build_dir / "dummy.txt").write_text("x") + (build_dir / "other.log").write_text("log content") + + # Call clean_all + from esphome.writer import clean_all + + with caplog.at_level("INFO"): + clean_all([str(config_dir)]) + + # Verify .esphome directory still exists + assert build_dir.exists() + + # Verify .json files are preserved + assert (build_dir / "config.json").exists() + assert (build_dir / "config.json").read_text() == '{"config": "data"}' + assert (build_dir / "metadata.json").exists() + assert (build_dir / "metadata.json").read_text() == '{"metadata": "info"}' + + # Verify non-.json files were removed + assert not (build_dir / "dummy.txt").exists() + assert not (build_dir / "other.log").exists() + + # Verify logging mentions cleaning + assert "Cleaning" in caplog.text + assert str(build_dir) in caplog.text From 9fb254fdc21a97d7f22aa682a88a6e33c6ef94f9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Oct 2025 11:23:44 -1000 Subject: [PATCH 9/9] Fix log retrieval with FQDN when mDNS is disabled (#11202) --- esphome/__main__.py | 15 +++++++----- tests/unit_tests/test_main.py | 45 ++++++++++++++++++++++++++++------- 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index be4551f6b5..8e0c475525 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -268,8 +268,10 @@ def has_ip_address() -> bool: def has_resolvable_address() -> bool: - """Check if CORE.address is resolvable (via mDNS or is an IP address).""" - return has_mdns() or has_ip_address() + """Check if CORE.address is resolvable (via mDNS, DNS, or is an IP address).""" + # Any address (IP, mDNS hostname, or regular DNS hostname) is resolvable + # The resolve_ip_address() function in helpers.py handles all types via AsyncResolver + return CORE.address is not None def mqtt_get_ip(config: ConfigType, username: str, password: str, client_id: str): @@ -578,11 +580,12 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int if has_api(): addresses_to_use: list[str] | None = None - if port_type == "NETWORK" and (has_mdns() or is_ip_address(port)): + if port_type == "NETWORK": + # Network addresses (IPs, mDNS names, or regular DNS hostnames) can be used + # The resolve_ip_address() function in helpers.py handles all types addresses_to_use = devices - elif port_type in ("NETWORK", "MQTT", "MQTTIP") and has_mqtt_ip_lookup(): - # Only use MQTT IP lookup if the first condition didn't match - # (for MQTT/MQTTIP types, or for NETWORK when mdns/ip check fails) + elif port_type in ("MQTT", "MQTTIP") and has_mqtt_ip_lookup(): + # Use MQTT IP lookup for MQTT/MQTTIP types addresses_to_use = mqtt_get_ip( config, args.username, args.password, args.client_id ) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index e35378145a..becf911fa3 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -1203,6 +1203,31 @@ def test_show_logs_api( ) +@patch("esphome.components.api.client.run_logs") +def test_show_logs_api_with_fqdn_mdns_disabled( + mock_run_logs: Mock, +) -> None: + """Test show_logs with API using FQDN when mDNS is disabled.""" + setup_core( + config={ + "logger": {}, + CONF_API: {}, + CONF_MDNS: {CONF_DISABLED: True}, + }, + platform=PLATFORM_ESP32, + ) + mock_run_logs.return_value = 0 + + args = MockArgs() + devices = ["device.example.com"] + + result = show_logs(CORE.config, args, devices) + + assert result == 0 + # Should use the FQDN directly, not try MQTT lookup + mock_run_logs.assert_called_once_with(CORE.config, ["device.example.com"]) + + @patch("esphome.components.api.client.run_logs") def test_show_logs_api_with_mqtt_fallback( mock_run_logs: Mock, @@ -1222,7 +1247,7 @@ def test_show_logs_api_with_mqtt_fallback( mock_mqtt_get_ip.return_value = ["192.168.1.200"] args = MockArgs(username="user", password="pass", client_id="client") - devices = ["device.local"] + devices = ["MQTTIP"] result = show_logs(CORE.config, args, devices) @@ -1487,27 +1512,31 @@ def test_mqtt_get_ip() -> None: def test_has_resolvable_address() -> None: """Test has_resolvable_address function.""" - # Test with mDNS enabled and hostname address + # Test with mDNS enabled and .local hostname address setup_core(config={}, address="esphome-device.local") assert has_resolvable_address() is True - # Test with mDNS disabled and hostname address + # Test with mDNS disabled and .local hostname address (still resolvable via DNS) setup_core( config={CONF_MDNS: {CONF_DISABLED: True}}, address="esphome-device.local" ) - assert has_resolvable_address() is False + assert has_resolvable_address() is True - # Test with IP address (mDNS doesn't matter) + # Test with mDNS disabled and regular DNS hostname (resolvable) + setup_core(config={CONF_MDNS: {CONF_DISABLED: True}}, address="device.example.com") + assert has_resolvable_address() is True + + # Test with IP address (always resolvable, mDNS doesn't matter) setup_core(config={}, address="192.168.1.100") assert has_resolvable_address() is True - # Test with IP address and mDNS disabled + # Test with IP address and mDNS disabled (still resolvable) setup_core(config={CONF_MDNS: {CONF_DISABLED: True}}, address="192.168.1.100") assert has_resolvable_address() is True - # Test with no address but mDNS enabled (can still resolve mDNS names) + # Test with no address setup_core(config={}, address=None) - assert has_resolvable_address() is True + assert has_resolvable_address() is False # Test with no address and mDNS disabled setup_core(config={CONF_MDNS: {CONF_DISABLED: True}}, address=None)