[json][web_server] Link strings that outlive the document instead of copying them (#19380)

This commit is contained in:
J. Nick Koston
2026-10-11 15:55:51 -10:00
committed by GitHub
parent fa4bc28d3e
commit c6ed2069a5
5 changed files with 87 additions and 45 deletions
+12
View File
@@ -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.
@@ -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)
+66 -42
View File
@@ -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<ProgmemStr>(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<const char *>(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<const char *>(id_buf);
if (start_config == DETAIL_ALL) {
root[ESPHOME_F("domain")] = prefix;
// Use .c_str() to avoid instantiating set<StringRef> 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<JsonArray>();
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<JsonArray>();
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<JsonArray>();
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<JsonArray>();
@@ -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<JsonArray>();
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<JsonArray>();
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
@@ -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();
+6
View File
@@ -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