[web_server] Reduce flash used by the JSON and request helpers (#19303)

This commit is contained in:
J. Nick Koston
2026-09-17 08:35:02 -05:00
committed by GitHub
parent 40934484c6
commit 28b2a689a7
5 changed files with 47 additions and 32 deletions
+2
View File
@@ -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
+3
View File
@@ -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<JsonObject>();
+39 -29
View File
@@ -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<uint32_t>(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<const char *>(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<uint32_t>(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<uint32_t>(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<const char *>(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<typename T>
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<typename S, typename T>
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<const char *, const char *>(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<const char *, const char *>(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<const char *, const char *>(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;
+1 -1
View File
@@ -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
@@ -322,7 +322,7 @@ class AsyncEventSource : public AsyncWebHandler {
using connect_handler_t = std::function<void(AsyncEventSourceClient *)>;
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<AsyncEventSourceResponse *> sessions_;
// Httpd-task intake; guarded by pending_mutex_, gated by has_pending_sessions_.