From 28b2a689a722313715664936fd7fcc3424f7e29f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 17 Sep 2026 08:35:02 -0500 Subject: [PATCH] [web_server] Reduce flash used by the JSON and request helpers (#19303) --- esphome/components/json/json_util.cpp | 2 + esphome/components/json/json_util.h | 3 + esphome/components/web_server/web_server.cpp | 68 +++++++++++-------- esphome/components/web_server/web_server.h | 2 +- .../web_server_idf/web_server_idf.h | 4 +- 5 files changed, 47 insertions(+), 32 deletions(-) diff --git a/esphome/components/json/json_util.cpp b/esphome/components/json/json_util.cpp index 984134b95f9..1b1eefe59b3 100644 --- a/esphome/components/json/json_util.cpp +++ b/esphome/components/json/json_util.cpp @@ -66,6 +66,8 @@ JsonDocument parse_json(const uint8_t *data, size_t len) { // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks,clang-analyzer-core.StackAddressEscape) } +JsonBuilder::JsonBuilder() = default; + SerializationBuffer<> JsonBuilder::serialize() { // =========================================================================================== // CRITICAL: NRVO (Named Return Value Optimization) - DO NOT REFACTOR WITHOUT UNDERSTANDING diff --git a/esphome/components/json/json_util.h b/esphome/components/json/json_util.h index 9f51d9927b8..130e1503321 100644 --- a/esphome/components/json/json_util.h +++ b/esphome/components/json/json_util.h @@ -168,6 +168,9 @@ inline JsonDocument parse_json(const std::string &data) { /// Builder class for creating JSON documents without lambdas class JsonBuilder { public: + // Out of line: inlining the JsonDocument constructor duplicates it at every call site + JsonBuilder(); + JsonObject root() { if (!root_created_) { root_ = doc_.to(); diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 1683492da76..8b0dfe166fb 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -66,9 +66,12 @@ static const char *const TAG = "web_server"; // GET /{domain}/{device_name}/{entity_name} - sub-device state (USE_DEVICES only) // POST /{domain}/{device_name}/{entity_name}/{action} - sub-device action (USE_DEVICES only) static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain, bool is_post = false) { + // Every path returns this one object so it is built in place; fields are only set once the URL is known valid + UrlMatch match{}; + // URL must start with '/' and have content after it if (url_len < 2 || url_ptr[0] != '/') - return UrlMatch{}; + return match; const char *p = url_ptr + 1; const char *end = url_ptr + url_len; @@ -90,15 +93,14 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain, // Must have domain with trailing slash if (!s2) - return UrlMatch{}; - - UrlMatch match{}; - match.domain = make_ref(s1, s2); - match.valid = true; - - if (only_domain || s2 >= end) return match; + if (only_domain || s2 >= end) { + match.domain = make_ref(s1, s2); + match.valid = true; + return match; + } + // Parse remaining segments only when needed const char *s3 = next_segment(s2); const char *s4 = s3 ? next_segment(s3) : nullptr; @@ -109,7 +111,7 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain, // Reject empty segments if (seg2.empty() || (s3 && seg3.empty()) || (s4 && seg4.empty())) - return UrlMatch{}; + return match; // Interpret based on segment count if (!s3) { @@ -121,28 +123,31 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain, if (is_post) { match.id = seg2; match.method = seg3; - return match; - } + } else { #ifdef USE_DEVICES - match.device_name = seg2; - match.id = seg3; + match.device_name = seg2; + match.id = seg3; #else - return UrlMatch{}; // 3-segment GET not supported without USE_DEVICES + return match; // 3-segment GET not supported without USE_DEVICES #endif + } } else { // 3 segments after domain: /{domain}/{device}/{entity}/{action} #ifdef USE_DEVICES if (!is_post) { - return UrlMatch{}; // 4-segment GET not supported (action requires POST) + return match; // 4-segment GET not supported (action requires POST) } match.device_name = seg2; match.id = seg3; match.method = seg4; #else - return UrlMatch{}; // Not supported without USE_DEVICES + // Not supported without USE_DEVICES + return match; #endif } + match.domain = make_ref(s1, s2); + match.valid = true; return match; } @@ -336,6 +341,9 @@ void DeferredUpdateEventSourceList::on_client_disconnect_(DeferredUpdateEventSou WebServer::WebServer(web_server_base::WebServerBase *base) : base_(base) {} +// Kept out of the callers so the 64 bit division is emitted once +__attribute__((noinline)) static uint32_t uptime_seconds() { return static_cast(millis_64() / 1000); } + json::SerializationBuffer<> WebServer::get_config_json() { json::JsonBuilder builder; JsonObject root = builder.root(); @@ -343,7 +351,7 @@ json::SerializationBuffer<> WebServer::get_config_json() { root[ESPHOME_F("title")] = App.get_friendly_name().empty() ? App.get_name().c_str() : App.get_friendly_name().c_str(); char comment_buffer[Application::ESPHOME_COMMENT_SIZE_MAX]; App.get_comment_string(comment_buffer); - root[ESPHOME_F("comment")] = comment_buffer; + root[ESPHOME_F("comment")] = static_cast(comment_buffer); #if defined(USE_WEBSERVER_OTA_DISABLED) || !defined(USE_WEBSERVER_OTA) root[ESPHOME_F("ota")] = false; // Note: USE_WEBSERVER_OTA_DISABLED only affects web_server, not captive_portal #else @@ -351,7 +359,7 @@ json::SerializationBuffer<> WebServer::get_config_json() { #endif root[ESPHOME_F("log")] = this->expose_log_; root[ESPHOME_F("lang")] = "en"; - root[ESPHOME_F("uptime")] = static_cast(millis_64() / 1000); + root[ESPHOME_F("uptime")] = uptime_seconds(); return builder.serialize(); } @@ -382,7 +390,7 @@ void WebServer::setup() { if (this->events_.empty()) return; char buf[32]; - auto uptime = static_cast(millis_64() / 1000); + auto uptime = uptime_seconds(); size_t len = buf_append_printf(buf, sizeof(buf), 0, "{\"uptime\":%" PRIu32 "}", uptime); this->events_.try_send_nodefer(buf, len, "ping", millis(), 30000); }); @@ -467,7 +475,10 @@ bool WebServer::is_request_origin_allowed_(AsyncWebServerRequest *request, const const size_t scheme_sep = origin.find("://"); if (scheme_sep != std::string::npos) { const std::string host = get_request_header(request, "Host"); - if (!host.empty() && origin.compare(scheme_sep + 3, std::string::npos, host) == 0) + // Compare by hand: compare(pos, ...) carries an out_of_range throw path that can never fire here + const size_t authority = scheme_sep + 3; + if (!host.empty() && origin.size() - authority == host.size() && + memcmp(origin.data() + authority, host.data(), host.size()) == 0) return true; } @@ -534,7 +545,7 @@ void WebServer::handle_js_request(AsyncWebServerRequest *request) { // Helper functions to reduce code size by avoiding macro expansion // Build unique id as: {domain}/{device_name}/{entity_name} or {domain}/{entity_name} // Uses names (not object_id) to avoid UTF-8 collision issues -static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, JsonDetail start_config) { +static void set_json_id(JsonObject root, EntityBase *obj, const char *prefix, JsonDetail start_config) { const StringRef &name = obj->get_name(); size_t prefix_len = strlen(prefix); size_t name_len = name.size(); @@ -569,7 +580,7 @@ static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, J #endif memcpy(p, name.c_str(), name_len); p[name_len] = '\0'; - root[ESPHOME_F("id")] = id_buf; + root[ESPHOME_F("id")] = static_cast(id_buf); if (start_config == DETAIL_ALL) { root[ESPHOME_F("domain")] = prefix; @@ -594,14 +605,13 @@ static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, J // Keep as separate function even though only used once: reduces code size by ~48 bytes // by allowing compiler to share code between template instantiations (bool, float, etc.) template -static void set_json_value(JsonObject &root, EntityBase *obj, const char *prefix, const T &value, - JsonDetail start_config) { +static void set_json_value(JsonObject root, EntityBase *obj, const char *prefix, T value, JsonDetail start_config) { set_json_id(root, obj, prefix, start_config); root[ESPHOME_F("value")] = value; } template -static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const char *prefix, S state, const T &value, +static void set_json_icon_state_value(JsonObject root, EntityBase *obj, const char *prefix, S state, T value, JsonDetail start_config) { set_json_value(root, obj, prefix, value, start_config); root[ESPHOME_F("state")] = state; @@ -1230,7 +1240,7 @@ json::SerializationBuffer<> WebServer::date_json_(datetime::DateEntity *obj, Jso // Format: YYYY-MM-DD (max 10 chars + null) char value[12]; buf_append_printf(value, sizeof(value), 0, "%d-%02d-%02d", obj->year, obj->month, obj->day); - set_json_icon_state_value(root, obj, "date", value, value, start_config); + set_json_icon_state_value(root, obj, "date", value, value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1290,7 +1300,7 @@ json::SerializationBuffer<> WebServer::time_json_(datetime::TimeEntity *obj, Jso // Format: HH:MM:SS (8 chars + null) char value[12]; buf_append_printf(value, sizeof(value), 0, "%02d:%02d:%02d", obj->hour, obj->minute, obj->second); - set_json_icon_state_value(root, obj, "time", value, value, start_config); + set_json_icon_state_value(root, obj, "time", value, value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1351,7 +1361,7 @@ json::SerializationBuffer<> WebServer::datetime_json_(datetime::DateTimeEntity * char value[24]; buf_append_printf(value, sizeof(value), 0, "%d-%02d-%02d %02d:%02d:%02d", obj->year, obj->month, obj->day, obj->hour, obj->minute, obj->second); - set_json_icon_state_value(root, obj, "datetime", value, value, start_config); + set_json_icon_state_value(root, obj, "datetime", value, value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -2295,7 +2305,7 @@ json::SerializationBuffer<> WebServer::update_json_(update::UpdateEntity *obj, J JsonObject root = builder.root(); set_json_icon_state_value(root, obj, "update", json_state_str(update::update_state_to_string(obj->state)), - obj->update_info.latest_version, start_config); + obj->update_info.latest_version.c_str(), start_config); if (start_config == DETAIL_ALL) { root[ESPHOME_F("current_version")] = obj->update_info.current_version; root[ESPHOME_F("title")] = obj->update_info.title; diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index d60b39278aa..7aa4ac24a3b 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -593,7 +593,7 @@ class WebServer final : public Controller, public Component, public AsyncWebHand web_server_base::WebServerBase *base_; #ifdef USE_ESP32 - AsyncEventSource events_{"/events", this}; + AsyncEventSource events_{StringRef::from_lit("/events"), this}; #elif USE_ARDUINO DeferredUpdateEventSourceList events_; #endif diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 6469b4c5648..743d296d73a 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -322,7 +322,7 @@ class AsyncEventSource : public AsyncWebHandler { using connect_handler_t = std::function; public: - AsyncEventSource(std::string url, esphome::web_server::WebServer *ws) : url_(std::move(url)), web_server_(ws) {} + AsyncEventSource(StringRef url, esphome::web_server::WebServer *ws) : url_(url), web_server_(ws) {} ~AsyncEventSource() override; // NOLINTNEXTLINE(readability-identifier-naming) @@ -352,7 +352,7 @@ class AsyncEventSource : public AsyncWebHandler { // Cold path: move sessions from pending_sessions_ into sessions_ and greet each one. void __attribute__((noinline, cold)) adopt_pending_sessions_main_loop_(); - std::string url_; + StringRef url_; // Must outlive this object (string literal) // Main-loop only. Vector: SSE sessions are 1-5 connections, linear search beats set. std::vector sessions_; // Httpd-task intake; guarded by pending_mutex_, gated by has_pending_sessions_.