From c6ed2069a566ea8f92b94828d0e2635718b342ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Oct 2026 15:55:51 -1000 Subject: [PATCH] [json][web_server] Link strings that outlive the document instead of copying them (#19380) --- esphome/components/json/json_util.h | 12 ++ .../components/light/light_json_schema.cpp | 4 +- esphome/components/web_server/web_server.cpp | 108 +++++++++++------- .../web_server_idf/web_server_idf.cpp | 2 +- esphome/core/string_ref.h | 6 + 5 files changed, 87 insertions(+), 45 deletions(-) diff --git a/esphome/components/json/json_util.h b/esphome/components/json/json_util.h index be12d0aa38a..9b4cc53a2e8 100644 --- a/esphome/components/json/json_util.h +++ b/esphome/components/json/json_util.h @@ -8,6 +8,7 @@ #include "esphome/core/defines.h" #include "esphome/core/helpers.h" +#include "esphome/core/progmem.h" #define ARDUINOJSON_ENABLE_STD_STRING 1 // NOLINT @@ -17,6 +18,17 @@ namespace esphome::json { +// Link a string into the document instead of copying it. Only for a literal, a codegen table +// entry or a name set once during setup; never a stack buffer or an entity state. +inline JsonString linked(const char *s) { return JsonString(s, true); } +inline JsonString linked(const char *s, size_t len) { return JsonString(s, len, true); } +inline JsonString linked(const std::string &s) { return JsonString(s.c_str(), s.size(), true); } +JsonString linked(std::string &&) = delete; // a temporary dies before the document is serialized +#ifdef USE_ESP8266 +// A PROGMEM string is copied out of flash +inline ProgmemStr linked(ProgmemStr s) { return s; } +#endif + /// Buffer for JSON serialization that uses stack allocation for small payloads. /// Template parameter STACK_SIZE specifies the stack buffer size (default 512 bytes). /// Supports move semantics for efficient return-by-value. diff --git a/esphome/components/light/light_json_schema.cpp b/esphome/components/light/light_json_schema.cpp index 98df98ce5d6..40e9c0b1aaf 100644 --- a/esphome/components/light/light_json_schema.cpp +++ b/esphome/components/light/light_json_schema.cpp @@ -27,7 +27,7 @@ static ProgmemStr get_color_mode_json_str(ColorMode mode) { void LightJSONSchema::dump_json(LightState &state, JsonObject root) { // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson if (state.supports_effects()) { - root[ESPHOME_F("effect")] = state.get_effect_name().c_str(); + root[ESPHOME_F("effect")] = json::linked(state.get_effect_name()); root[ESPHOME_F("effect_index")] = state.get_current_effect_index(); root[ESPHOME_F("effect_count")] = state.get_effect_count(); } @@ -37,7 +37,7 @@ void LightJSONSchema::dump_json(LightState &state, JsonObject root) { const auto color_mode = values.get_color_mode(); const auto *mode_str = get_color_mode_json_str(color_mode); if (mode_str != nullptr) { - root[ESPHOME_F("color_mode")] = mode_str; + root[ESPHOME_F("color_mode")] = json::linked(mode_str); } if (color_mode & ColorCapability::ON_OFF) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 323857c37e8..5fa3ea054b5 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -57,8 +57,25 @@ namespace esphome::web_server { ESPHOME_LOG_TAG(TAG, "web_server"); +using json::linked; + +#ifdef USE_STORE_LOG_STR_IN_FLASH // View a state LogString as a ProgmemStr so ArduinoJson serializes it PROGMEM-aware on ESP8266. [[maybe_unused]] static ProgmemStr json_state_str(const LogString *s) { return reinterpret_cast(s); } +#else +// Without the define a LogString is in RAM and can be linked +[[maybe_unused]] static JsonString json_state_str(const LogString *s) { return linked(LOG_STR_ARG(s)); } +#endif + +// get_icon_to() and get_device_class_to() return a table pointer, except on ESP8266 where they +// copy out of PROGMEM into the caller's buffer +[[maybe_unused]] static JsonString json_table_str(const char *s) { +#ifdef USE_ESP8266 + return JsonString(s); +#else + return linked(s); +#endif +} // One action of a POST /{domain}/{entity}/{action} request, kept in PROGMEM with the name inline so the // table needs no RAM on ESP8266. @@ -348,7 +365,7 @@ void DeferredUpdateEventSourceList::on_client_connect_(DeferredUpdateEventSource for (auto &group : ws->sorting_groups_) { json::JsonBuilder builder; JsonObject root = builder.root(); - root[ESPHOME_F("name")] = group.second.name; + root[ESPHOME_F("name")] = linked(group.second.name); root[ESPHOME_F("sorting_weight")] = group.second.weight; auto group_msg = builder.serialize(); @@ -380,7 +397,7 @@ json::SerializationBuffer<> WebServer::get_config_json() { json::JsonBuilder builder; JsonObject root = builder.root(); - root[ESPHOME_F("title")] = App.get_friendly_name().empty() ? App.get_name().c_str() : App.get_friendly_name().c_str(); + root[ESPHOME_F("title")] = linked(App.get_friendly_name().empty() ? App.get_name() : App.get_friendly_name()); char comment_buffer[Application::ESPHOME_COMMENT_SIZE_MAX]; App.get_comment_string(comment_buffer); root[ESPHOME_F("comment")] = static_cast(comment_buffer); @@ -616,17 +633,16 @@ static void set_json_id(JsonObject root, EntityBase *obj, const char *prefix, Js root[ESPHOME_F("id")] = static_cast(id_buf); if (start_config == DETAIL_ALL) { - root[ESPHOME_F("domain")] = prefix; - // Use .c_str() to avoid instantiating set template (saves ~24B) - root[ESPHOME_F("name")] = name.c_str(); + root[ESPHOME_F("domain")] = linked(prefix, prefix_len); + root[ESPHOME_F("name")] = linked(name); #ifdef USE_DEVICES if (device_name) { - root[ESPHOME_F("device")] = device_name; + root[ESPHOME_F("device")] = linked(device_name, device_len); } #endif #ifdef USE_ENTITY_ICON char icon_buf[MAX_ICON_LENGTH]; - root[ESPHOME_F("icon")] = obj->get_icon_to(icon_buf); + root[ESPHOME_F("icon")] = json_table_str(obj->get_icon_to(icon_buf)); #endif root[ESPHOME_F("entity_category")] = obj->get_entity_category(); bool is_disabled = obj->is_disabled_by_default(); @@ -695,7 +711,7 @@ void WebServer::sensor_json_(sensor::Sensor *obj, float value, JsonDetail start_ if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); if (!uom_ref.empty()) - root[ESPHOME_F("uom")] = uom_ref.c_str(); + root[ESPHOME_F("uom")] = linked(uom_ref); } } #endif @@ -734,7 +750,9 @@ void WebServer::text_sensor_json_(text_sensor::TextSensor *obj, const std::strin json::JsonBuilder &builder) { JsonObject root = builder.root(); - set_json_icon_state_value(root, obj, "text_sensor", value.c_str(), value.c_str(), start_config); + // Not linked: the main loop can rewrite the state before the document is serialized + const JsonString state(value.c_str(), value.size()); + set_json_icon_state_value(root, obj, "text_sensor", state, state, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1041,9 +1059,9 @@ void WebServer::light_json_(light::LightState *obj, JsonDetail start_config, jso light::LightJSONSchema::dump_json(*obj, root); if (start_config == DETAIL_ALL) { JsonArray opt = root[ESPHOME_F("effects")].to(); - opt.add("None"); + opt.add(linked("None")); for (auto const &option : obj->get_effects()) { - opt.add(option->get_name()); + opt.add(linked(option->get_name())); } this->add_sorting_info_(root, obj); } @@ -1109,7 +1127,7 @@ void WebServer::cover_all_json_generator(WebServer *web_server, void *source, js void WebServer::cover_json_(cover::Cover *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); - set_json_icon_state_value(root, obj, "cover", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, + set_json_icon_state_value(root, obj, "cover", linked(obj->is_fully_closed() ? "CLOSED" : "OPEN"), obj->position, start_config); root[ESPHOME_F("current_operation")] = json_state_str(cover::cover_operation_to_str(obj->current_operation)); @@ -1173,9 +1191,14 @@ void WebServer::number_json_(number::Number *obj, float value, JsonDetail start_ // Need two buffers: one for value, one for state with UOM char val_buf[VALUE_ACCURACY_MAX_LEN]; char state_buf[VALUE_ACCURACY_MAX_LEN]; - const char *val_str = std::isnan(value) ? "\"NaN\"" : (value_accuracy_to_buf(val_buf, value, accuracy), val_buf); - const char *state_str = - std::isnan(value) ? "NA" : (value_accuracy_with_uom_to_buf(state_buf, value, accuracy, uom_ref), state_buf); + JsonString val_str = linked("\"NaN\""); + JsonString state_str = linked("NA"); + if (!std::isnan(value)) { + value_accuracy_to_buf(val_buf, value, accuracy); + value_accuracy_with_uom_to_buf(state_buf, value, accuracy, uom_ref); + val_str = JsonString(val_buf); + state_str = JsonString(state_buf); + } set_json_icon_state_value(root, obj, "number", state_str, val_str, start_config); if (start_config == DETAIL_ALL) { // ArduinoJson copies the string immediately, so we can reuse val_buf @@ -1184,7 +1207,7 @@ void WebServer::number_json_(number::Number *obj, float value, JsonDetail start_ root[ESPHOME_F("step")] = (value_accuracy_to_buf(val_buf, obj->traits.get_step(), accuracy), val_buf); root[ESPHOME_F("mode")] = (int) obj->traits.get_mode(); if (!uom_ref.empty()) - root[ESPHOME_F("uom")] = uom_ref.c_str(); + root[ESPHOME_F("uom")] = linked(uom_ref); this->add_sorting_info_(root, obj); } } @@ -1412,14 +1435,16 @@ void WebServer::text_json_(text::Text *obj, const std::string &value, JsonDetail json::JsonBuilder &builder) { JsonObject root = builder.root(); + // Not linked: the main loop can rewrite the state before the document is serialized + const JsonString copied_value(value.c_str(), value.size()); // A password entity shows the mask and prefills the input with nothing, so the secret never // reaches the JSON and the mask cannot be written back as the value const bool password = obj->traits.get_mode() == text::TextMode::TEXT_MODE_PASSWORD; - set_json_icon_state_value(root, obj, "text", password ? "********" : value.c_str(), password ? "" : value.c_str(), - start_config); + set_json_icon_state_value(root, obj, "text", password ? linked("********") : copied_value, + password ? linked("") : copied_value, start_config); root[ESPHOME_F("min_length")] = obj->traits.get_min_length(); root[ESPHOME_F("max_length")] = obj->traits.get_max_length(); - root[ESPHOME_F("pattern")] = obj->traits.get_pattern_c_str(); + root[ESPHOME_F("pattern")] = linked(obj->traits.get_pattern_c_str()); if (start_config == DETAIL_ALL) { root[ESPHOME_F("mode")] = (int) obj->traits.get_mode(); this->add_sorting_info_(root, obj); @@ -1475,12 +1500,12 @@ void WebServer::select_json_(select::Select *obj, StringRef value, JsonDetail st json::JsonBuilder &builder) { JsonObject root = builder.root(); - // value points to null-terminated string literals from codegen (via current_option()) - set_json_icon_state_value(root, obj, "select", value.c_str(), value.c_str(), start_config); + const JsonString state = linked(value); + set_json_icon_state_value(root, obj, "select", state, state, start_config); if (start_config == DETAIL_ALL) { JsonArray opt = root[ESPHOME_F("option")].to(); for (auto &option : obj->traits.get_options()) { - opt.add(option); + opt.add(linked(option)); } this->add_sorting_info_(root, obj); } @@ -1574,7 +1599,7 @@ void WebServer::climate_json_(climate::Climate *obj, JsonDetail start_config, js if (!traits.get_supported_custom_fan_modes().empty()) { JsonArray opt = root[ESPHOME_F("custom_fan_modes")].to(); for (auto const &custom_fan_mode : traits.get_supported_custom_fan_modes()) - opt.add(custom_fan_mode); + opt.add(linked(custom_fan_mode)); } if (traits.get_supports_swing_modes()) { JsonArray opt = root[ESPHOME_F("swing_modes")].to(); @@ -1589,7 +1614,7 @@ void WebServer::climate_json_(climate::Climate *obj, JsonDetail start_config, js if (!traits.get_supported_custom_presets().empty()) { JsonArray opt = root[ESPHOME_F("custom_presets")].to(); for (auto const &custom_preset : traits.get_supported_custom_presets()) - opt.add(custom_preset); + opt.add(linked(custom_preset)); } root[ESPHOME_F("max_temp")] = (value_accuracy_to_buf(temp_buf, traits.get_visual_max_temperature(), target_accuracy), temp_buf); @@ -1611,13 +1636,13 @@ void WebServer::climate_json_(climate::Climate *obj, JsonDetail start_config, js root[ESPHOME_F("fan_mode")] = json_state_str(climate_fan_mode_to_string(obj->fan_mode.value())); } if (!traits.get_supported_custom_fan_modes().empty() && obj->has_custom_fan_mode()) { - root[ESPHOME_F("custom_fan_mode")] = obj->get_custom_fan_mode(); + root[ESPHOME_F("custom_fan_mode")] = linked(obj->get_custom_fan_mode()); } if (traits.get_supports_presets() && obj->preset.has_value()) { root[ESPHOME_F("preset")] = json_state_str(climate_preset_to_string(obj->preset.value())); } if (!traits.get_supported_custom_presets().empty() && obj->has_custom_preset()) { - root[ESPHOME_F("custom_preset")] = obj->get_custom_preset(); + root[ESPHOME_F("custom_preset")] = linked(obj->get_custom_preset()); } if (traits.get_supports_swing_modes()) { root[ESPHOME_F("swing_mode")] = json_state_str(climate_swing_mode_to_string(obj->swing_mode)); @@ -1788,7 +1813,7 @@ void WebServer::valve_all_json_generator(WebServer *web_server, void *source, js void WebServer::valve_json_(valve::Valve *obj, JsonDetail start_config, json::JsonBuilder &builder) { JsonObject root = builder.root(); - set_json_icon_state_value(root, obj, "valve", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, + set_json_icon_state_value(root, obj, "valve", linked(obj->is_fully_closed() ? "CLOSED" : "OPEN"), obj->position, start_config); root[ESPHOME_F("current_operation")] = json_state_str(valve::valve_operation_to_str(obj->current_operation)); @@ -1936,9 +1961,8 @@ void WebServer::water_heater_json_(water_heater::WaterHeater *obj, JsonDetail st JsonObject root = builder.root(); const auto mode = obj->get_mode(); - ProgmemStr mode_s = json_state_str(water_heater::water_heater_mode_to_string(mode)); - - set_json_icon_state_value(root, obj, "water_heater", mode_s, mode, start_config); + set_json_icon_state_value(root, obj, "water_heater", json_state_str(water_heater::water_heater_mode_to_string(mode)), + mode, start_config); auto traits = obj->get_traits(); @@ -2209,15 +2233,15 @@ void WebServer::event_json_(event::Event *obj, StringRef event_type, JsonDetail set_json_id(root, obj, "event", start_config); if (!event_type.empty()) { - root[ESPHOME_F("event_type")] = event_type; + root[ESPHOME_F("event_type")] = linked(event_type); } if (start_config == DETAIL_ALL) { JsonArray event_types = root[ESPHOME_F("event_types")].to(); for (const char *event_type : obj->get_event_types()) { - event_types.add(event_type); + event_types.add(linked(event_type)); } char dc_buf[MAX_DEVICE_CLASS_LENGTH]; - root[ESPHOME_F("device_class")] = obj->get_device_class_to(dc_buf); + root[ESPHOME_F("device_class")] = json_table_str(obj->get_device_class_to(dc_buf)); this->add_sorting_info_(root, obj); } } @@ -2265,17 +2289,17 @@ void WebServer::update_json_(update::UpdateEntity *obj, JsonDetail start_config, // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson JsonObject root = builder.root(); + // Not linked: the main loop can rewrite update_info before the document is serialized + const auto &info = obj->update_info; set_json_icon_state_value(root, obj, "update", json_state_str(update::update_state_to_string(obj->state)), - obj->update_info.latest_version.c_str(), start_config); + 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; - // Truncate long changelogs — full text available via release_url + root[ESPHOME_F("current_version")] = info.current_version; + root[ESPHOME_F("title")] = info.title; + // Truncate long changelogs, the full text is available via release_url constexpr size_t max_summary_len = 256; - root[ESPHOME_F("summary")] = obj->update_info.summary.size() <= max_summary_len - ? obj->update_info.summary - : obj->update_info.summary.substr(0, max_summary_len); - root[ESPHOME_F("release_url")] = obj->update_info.release_url; + root[ESPHOME_F("summary")] = JsonString(info.summary.c_str(), std::min(info.summary.size(), max_summary_len)); + root[ESPHOME_F("release_url")] = info.release_url; this->add_sorting_info_(root, obj); } @@ -2610,7 +2634,7 @@ void WebServer::add_sorting_info_(JsonObject &root, EntityBase *entity) { if (this->sorting_entitys_.contains(entity)) { root[ESPHOME_F("sorting_weight")] = this->sorting_entitys_[entity].weight; if (this->sorting_groups_.contains(this->sorting_entitys_[entity].group_id)) { - root[ESPHOME_F("sorting_group")] = this->sorting_groups_[this->sorting_entitys_[entity].group_id].name; + root[ESPHOME_F("sorting_group")] = linked(this->sorting_groups_[this->sorting_entitys_[entity].group_id].name); } } #endif diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 9a6e419e439..953351f259b 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -850,7 +850,7 @@ void AsyncEventSourceResponse::start_session_main_loop_() { for (auto &group : ws->sorting_groups_) { json::JsonBuilder builder; JsonObject root = builder.root(); - root["name"] = group.second.name; + root["name"] = json::linked(group.second.name); root["sorting_weight"] = group.second.weight; message = builder.serialize(); diff --git a/esphome/core/string_ref.h b/esphome/core/string_ref.h index 4c0ce28a5ac..14275412294 100644 --- a/esphome/core/string_ref.h +++ b/esphome/core/string_ref.h @@ -272,6 +272,7 @@ inline double stod(const StringRef &str, size_t *pos = nullptr) { // NOLINTEND(readability-identifier-naming,google-runtime-int,readability-non-const-parameter) #ifdef USE_JSON +// A StringRef may view a scratch buffer (mqtt topics do), so the document copies it // NOLINTNEXTLINE(readability-identifier-naming) inline void convertToJson(const StringRef &src, JsonVariant dst) { // Bounded by the view length; a null, empty view becomes "" rather than JSON null @@ -283,6 +284,11 @@ inline void convertToJson(const StringRef &src, JsonVariant dst) { // constructor and asks for empty() instead dst.set(JsonString(src.c_str(), src.size(), /* isStatic= */ false)); } + +namespace json { +// For a StringRef that outlives the document, see json_util.h. A null, empty view links "" too +inline JsonString linked(const StringRef &s) { return JsonString(s.empty() ? "" : s.c_str(), s.size(), true); } +} // namespace json #endif // USE_JSON } // namespace esphome