1
0
mirror of https://github.com/esphome/esphome.git synced 2026-02-08 08:41:59 +00:00

Merge branch 'web_server_new_urls' into integration_object

This commit is contained in:
J. Nick Koston
2025-12-22 22:09:16 -10:00
10 changed files with 420 additions and 81 deletions

View File

@@ -53,8 +53,16 @@ static const char *const HEADER_CORS_ALLOW_PNA = "Access-Control-Allow-Private-N
#endif
// Parse URL and return match info
// URL formats:
// /{domain}/{entity_name} - main device, no method
// /{domain}/{entity_name}/{method} - main device with method
// /{domain}/{device_name}/{entity_name}/{method} - sub-device with method (USE_DEVICES only)
static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain) {
UrlMatch match{};
#ifdef USE_DEVICES
match.device_name = nullptr;
match.device_name_len = 0;
#endif
// URL must start with '/'
if (url_len < 2 || url_ptr[0] != '/') {
@@ -81,34 +89,139 @@ static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain)
return match;
}
// Parse ID if present
// Parse remaining segments
if (domain_end + 1 >= end) {
return match; // Nothing after domain slash
}
const char *id_start = domain_end + 1;
const char *id_end = (const char *) memchr(id_start, '/', end - id_start);
// Find all remaining slashes to count segments
const char *seg1_start = domain_end + 1;
const char *seg1_end = (const char *) memchr(seg1_start, '/', end - seg1_start);
if (!id_end) {
// No more slashes, entire remaining string is ID
match.id = id_start;
match.id_len = end - id_start;
if (!seg1_end) {
// Only 1 segment after domain: /{domain}/{entity_name}
match.id = seg1_start;
match.id_len = end - seg1_start;
// Reject empty segment (e.g., "/sensor/")
if (match.id_len == 0) {
return UrlMatch{};
}
return match;
}
// Set ID
match.id = id_start;
match.id_len = id_end - id_start;
const char *seg2_start = seg1_end + 1;
const char *seg2_end = (seg2_start < end) ? (const char *) memchr(seg2_start, '/', end - seg2_start) : nullptr;
// Parse method if present
if (id_end + 1 < end) {
match.method = id_end + 1;
match.method_len = end - (id_end + 1);
if (!seg2_end) {
// 2 segments after domain: /{domain}/{X}/{Y}
// This is /{domain}/{entity_name}/{method} for main device
match.id = seg1_start;
match.id_len = seg1_end - seg1_start;
match.method = seg2_start;
match.method_len = end - seg2_start;
// Reject empty segments (e.g., "/sensor//turn_on" or "/sensor/temp/")
if (match.id_len == 0 || match.method_len == 0) {
return UrlMatch{};
}
return match;
}
#ifdef USE_DEVICES
// 3+ segments after domain: /{domain}/{device_name}/{entity_name}/{method}
const char *seg3_start = seg2_end + 1;
match.device_name = seg1_start;
match.device_name_len = seg1_end - seg1_start;
match.id = seg2_start;
match.id_len = seg2_end - seg2_start;
if (seg3_start < end) {
match.method = seg3_start;
match.method_len = end - seg3_start;
} else {
// No method segment - fields already zero-initialized by UrlMatch{}
match.method = nullptr;
match.method_len = 0;
}
// Reject empty segments (e.g., "/sensor//entity/turn_on" or "/sensor/device//turn_on")
if (match.device_name_len == 0 || match.id_len == 0 || (match.method != nullptr && match.method_len == 0)) {
return UrlMatch{};
}
#else
// Without USE_DEVICES, reject URLs with 3+ segments (device paths not supported)
return UrlMatch{};
#endif
return match;
}
EntityMatchResult UrlMatch::match_entity(EntityBase *entity) const {
EntityMatchResult result{false, this->method_len == 0};
#ifdef USE_DEVICES
Device *entity_device = entity->get_device();
bool url_has_device = (this->device_name_len > 0);
bool entity_has_device = (entity_device != nullptr);
if (url_has_device) {
// URL has explicit device segment (3+ segments) - must match device
if (!entity_has_device)
return result;
const char *entity_device_name = entity_device->get_name();
if (this->device_name_len != strlen(entity_device_name) ||
memcmp(this->device_name, entity_device_name, this->device_name_len) != 0)
return result;
} else if (entity_has_device) {
// Entity has device but URL has only 2 segments (id/method)
// Try interpreting as device/entity: id=device_name, method=entity_name
if (this->method_len == 0)
return result; // Need 2 segments for this interpretation
const char *entity_device_name = entity_device->get_name();
if (this->id_len == strlen(entity_device_name) && memcmp(this->id, entity_device_name, this->id_len) == 0) {
const StringRef &name_ref = entity->get_name();
if (this->method_len == name_ref.size() && memcmp(this->method, name_ref.c_str(), this->method_len) == 0) {
// Matched: id=device, method=entity_name, so method is effectively empty
return {true, true};
}
}
return result; // No match
}
#endif
// Try matching by entity name (new format)
const StringRef &name_ref = entity->get_name();
if (this->id_matches(name_ref.c_str(), name_ref.size())) {
result.matched = true;
return result;
}
// Fall back to object_id (deprecated format)
char object_id_buf[OBJECT_ID_MAX_LEN];
StringRef object_id = entity->get_object_id_to(object_id_buf);
if (this->id_matches(object_id.c_str(), object_id.size())) {
result.matched = true;
// Log deprecation warning
#ifdef USE_DEVICES
Device *device = entity->get_device();
if (device != nullptr) {
ESP_LOGW(TAG,
"Deprecated URL format: /%.*s/%.*s/%.*s - use entity name '/%.*s/%s/%s' instead. "
"Object ID URLs will be removed in 2026.7.0.",
this->domain_len, this->domain, this->device_name_len, this->device_name, this->id_len, this->id,
this->domain_len, this->domain, device->get_name(), entity->get_name().c_str());
} else
#endif
{
ESP_LOGW(TAG,
"Deprecated URL format: /%.*s/%.*s - use entity name '/%.*s/%s' instead. "
"Object ID URLs will be removed in 2026.7.0.",
this->domain_len, this->domain, this->id_len, this->id, this->domain_len, this->domain,
entity->get_name().c_str());
}
}
return result;
}
#if !defined(USE_ESP32) && defined(USE_ARDUINO)
// helper for allowing only unique entries in the queue
void DeferredUpdateEventSource::deq_push_back_with_dedup_(void *source, message_generator_t *message_generator) {
@@ -405,15 +518,53 @@ void WebServer::handle_js_request(AsyncWebServerRequest *request) {
#endif
// 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) {
char id_buf[160]; // prefix + dash + object_id (up to 128) + null
size_t len = strlen(prefix);
memcpy(id_buf, prefix, len); // NOLINT(bugprone-not-null-terminated-result) - null added by write_object_id_to
id_buf[len++] = '-';
obj->write_object_id_to(id_buf + len, sizeof(id_buf) - len);
const StringRef &name = obj->get_name();
size_t prefix_len = strlen(prefix);
size_t name_len = name.size();
#ifdef USE_DEVICES
Device *device = obj->get_device();
const char *device_name = device ? device->get_name() : nullptr;
size_t device_len = device_name ? strlen(device_name) : 0;
#endif
// Build id into stack buffer - ArduinoJson copies the string
// Format: {prefix}/{device?}/{name}
// Buffer size guaranteed by schema validation (NAME_MAX_LENGTH=120):
// With devices: domain(20) + "/" + device(120) + "/" + name(120) + null = 263, rounded up to 280 for safety margin
// Without devices: domain(20) + "/" + name(120) + null = 142, rounded up to 150 for safety margin
#ifdef USE_DEVICES
char id_buf[280];
#else
char id_buf[150];
#endif
char *p = id_buf;
memcpy(p, prefix, prefix_len);
p += prefix_len;
*p++ = '/';
#ifdef USE_DEVICES
if (device_name) {
memcpy(p, device_name, device_len);
p += device_len;
*p++ = '/';
}
#endif
memcpy(p, name.c_str(), name_len);
p[name_len] = '\0';
root[ESPHOME_F("id")] = id_buf;
if (start_config == DETAIL_ALL) {
root[ESPHOME_F("name")] = obj->get_name();
root[ESPHOME_F("domain")] = prefix;
root[ESPHOME_F("name")] = name;
#ifdef USE_DEVICES
if (device_name) {
root[ESPHOME_F("device")] = device_name;
}
#endif
root[ESPHOME_F("icon")] = obj->get_icon_ref();
root[ESPHOME_F("entity_category")] = obj->get_entity_category();
bool is_disabled = obj->is_disabled_by_default();
@@ -452,10 +603,11 @@ void WebServer::on_sensor_update(sensor::Sensor *obj) {
}
void WebServer::handle_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (sensor::Sensor *obj : App.get_sensors()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
// Note: request->method() is always HTTP_GET here (canHandle ensures this)
if (match.method_empty()) {
if (entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->sensor_json_(obj, obj->state, detail);
request->send(200, "application/json", data.c_str());
@@ -498,10 +650,11 @@ void WebServer::on_text_sensor_update(text_sensor::TextSensor *obj) {
}
void WebServer::handle_text_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (text_sensor::TextSensor *obj : App.get_text_sensors()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
// Note: request->method() is always HTTP_GET here (canHandle ensures this)
if (match.method_empty()) {
if (entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->text_sensor_json_(obj, obj->state, detail);
request->send(200, "application/json", data.c_str());
@@ -540,10 +693,11 @@ void WebServer::on_switch_update(switch_::Switch *obj) {
}
void WebServer::handle_switch_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (switch_::Switch *obj : App.get_switches()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
if (request->method() == HTTP_GET && match.method_empty()) {
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->switch_json_(obj, obj->state, detail);
request->send(200, "application/json", data.c_str());
@@ -609,9 +763,10 @@ std::string WebServer::switch_json_(switch_::Switch *obj, bool value, JsonDetail
#ifdef USE_BUTTON
void WebServer::handle_button_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (button::Button *obj : App.get_buttons()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
if (request->method() == HTTP_GET && match.method_empty()) {
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->button_json_(obj, detail);
request->send(200, "application/json", data.c_str());
@@ -653,10 +808,11 @@ void WebServer::on_binary_sensor_update(binary_sensor::BinarySensor *obj) {
}
void WebServer::handle_binary_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (binary_sensor::BinarySensor *obj : App.get_binary_sensors()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
// Note: request->method() is always HTTP_GET here (canHandle ensures this)
if (match.method_empty()) {
if (entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->binary_sensor_json_(obj, obj->state, detail);
request->send(200, "application/json", data.c_str());
@@ -694,10 +850,11 @@ void WebServer::on_fan_update(fan::Fan *obj) {
}
void WebServer::handle_fan_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (fan::Fan *obj : App.get_fans()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
if (request->method() == HTTP_GET && match.method_empty()) {
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->fan_json_(obj, detail);
request->send(200, "application/json", data.c_str());
@@ -774,10 +931,11 @@ void WebServer::on_light_update(light::LightState *obj) {
}
void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (light::LightState *obj : App.get_lights()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
if (request->method() == HTTP_GET && match.method_empty()) {
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->light_json_(obj, detail);
request->send(200, "application/json", data.c_str());
@@ -852,10 +1010,11 @@ void WebServer::on_cover_update(cover::Cover *obj) {
}
void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (cover::Cover *obj : App.get_covers()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
if (request->method() == HTTP_GET && match.method_empty()) {
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->cover_json_(obj, detail);
request->send(200, "application/json", data.c_str());
@@ -940,10 +1099,11 @@ void WebServer::on_number_update(number::Number *obj) {
}
void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (auto *obj : App.get_numbers()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
if (request->method() == HTTP_GET && match.method_empty()) {
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->number_json_(obj, obj->state, detail);
request->send(200, "application/json", data.c_str());
@@ -1007,9 +1167,10 @@ void WebServer::on_date_update(datetime::DateEntity *obj) {
}
void WebServer::handle_date_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (auto *obj : App.get_dates()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
if (request->method() == HTTP_GET && match.method_empty()) {
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->date_json_(obj, detail);
request->send(200, "application/json", data.c_str());
@@ -1070,9 +1231,10 @@ void WebServer::on_time_update(datetime::TimeEntity *obj) {
}
void WebServer::handle_time_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (auto *obj : App.get_times()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
if (request->method() == HTTP_GET && match.method_empty()) {
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->time_json_(obj, detail);
request->send(200, "application/json", data.c_str());
@@ -1132,9 +1294,10 @@ void WebServer::on_datetime_update(datetime::DateTimeEntity *obj) {
}
void WebServer::handle_datetime_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (auto *obj : App.get_datetimes()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
if (request->method() == HTTP_GET && match.method_empty()) {
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->datetime_json_(obj, detail);
request->send(200, "application/json", data.c_str());
@@ -1196,10 +1359,11 @@ void WebServer::on_text_update(text::Text *obj) {
}
void WebServer::handle_text_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (auto *obj : App.get_texts()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
if (request->method() == HTTP_GET && match.method_empty()) {
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->text_json_(obj, obj->state, detail);
request->send(200, "application/json", data.c_str());
@@ -1252,10 +1416,11 @@ void WebServer::on_select_update(select::Select *obj) {
}
void WebServer::handle_select_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (auto *obj : App.get_selects()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
if (request->method() == HTTP_GET && match.method_empty()) {
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->select_json_(obj, obj->has_state() ? obj->current_option() : "", detail);
request->send(200, "application/json", data.c_str());
@@ -1309,10 +1474,11 @@ void WebServer::on_climate_update(climate::Climate *obj) {
}
void WebServer::handle_climate_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (auto *obj : App.get_climates()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
if (request->method() == HTTP_GET && match.method_empty()) {
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->climate_json_(obj, detail);
request->send(200, "application/json", data.c_str());
@@ -1459,10 +1625,11 @@ void WebServer::on_lock_update(lock::Lock *obj) {
}
void WebServer::handle_lock_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (lock::Lock *obj : App.get_locks()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
if (request->method() == HTTP_GET && match.method_empty()) {
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->lock_json_(obj, obj->state, detail);
request->send(200, "application/json", data.c_str());
@@ -1533,10 +1700,11 @@ void WebServer::on_valve_update(valve::Valve *obj) {
}
void WebServer::handle_valve_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (valve::Valve *obj : App.get_valves()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
if (request->method() == HTTP_GET && match.method_empty()) {
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->valve_json_(obj, detail);
request->send(200, "application/json", data.c_str());
@@ -1617,10 +1785,11 @@ void WebServer::on_alarm_control_panel_update(alarm_control_panel::AlarmControlP
}
void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (alarm_control_panel::AlarmControlPanel *obj : App.get_alarm_control_panels()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
if (request->method() == HTTP_GET && match.method_empty()) {
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->alarm_control_panel_json_(obj, obj->get_state(), detail);
request->send(200, "application/json", data.c_str());
@@ -1698,11 +1867,12 @@ void WebServer::on_event(event::Event *obj) {
void WebServer::handle_event_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (event::Event *obj : App.get_events()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
// Note: request->method() is always HTTP_GET here (canHandle ensures this)
if (match.method_empty()) {
if (entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->event_json_(obj, "", detail);
request->send(200, "application/json", data.c_str());
@@ -1767,10 +1937,11 @@ void WebServer::on_update(update::UpdateEntity *obj) {
}
void WebServer::handle_update_request(AsyncWebServerRequest *request, const UrlMatch &match) {
for (update::UpdateEntity *obj : App.get_updates()) {
if (!match.id_equals_entity(obj))
auto entity_match = match.match_entity(obj);
if (!entity_match.matched)
continue;
if (request->method() == HTTP_GET && match.method_empty()) {
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
auto detail = get_request_detail(request);
std::string data = this->update_json_(obj, detail);
request->send(200, "application/json", data.c_str());

View File

@@ -36,33 +36,44 @@ extern const size_t ESPHOME_WEBSERVER_JS_INCLUDE_SIZE;
namespace esphome {
namespace web_server {
/// Result of matching a URL against an entity
struct EntityMatchResult {
bool matched; ///< True if entity matched the URL
bool action_is_empty; ///< True if no action in URL (or action field was used as entity name for 2-seg subdevice)
};
/// Internal helper struct that is used to parse incoming URLs
/// Note: Length fields use uint8_t, so NAME_MAX_LENGTH in config_validation.py must stay < 255
struct UrlMatch {
const char *domain; ///< Pointer to domain within URL, for example "sensor"
const char *id; ///< Pointer to id within URL, for example "living_room_fan"
const char *id; ///< Pointer to entity name/id within URL, for example "Temperature"
const char *method; ///< Pointer to method within URL, for example "turn_on"
#ifdef USE_DEVICES
const char *device_name; ///< Pointer to device name within URL, or nullptr for main device
#endif
uint8_t domain_len; ///< Length of domain string
uint8_t id_len; ///< Length of id string
uint8_t id_len; ///< Length of id string (NAME_MAX_LENGTH must be < 255)
uint8_t method_len; ///< Length of method string
bool valid; ///< Whether this match is valid
#ifdef USE_DEVICES
uint8_t device_name_len; ///< Length of device name string (NAME_MAX_LENGTH must be < 255)
#endif
bool valid; ///< Whether this match is valid
// Helper methods for string comparisons
bool domain_equals(const char *str) const {
return domain && domain_len == strlen(str) && memcmp(domain, str, domain_len) == 0;
}
bool id_equals_entity(EntityBase *entity) const {
// Get object_id with zero heap allocation
char object_id_buf[OBJECT_ID_MAX_LEN];
StringRef object_id = entity->get_object_id_to(object_id_buf);
return id && id_len == object_id.size() && memcmp(id, object_id.c_str(), id_len) == 0;
}
/// Check if URL id segment matches a string (by pointer and length)
bool id_matches(const char *str, size_t len) const { return id && id_len == len && memcmp(id, str, len) == 0; }
/// Match entity by name first, then fall back to object_id with deprecation warning
/// Returns EntityMatchResult with match status and whether method is effectively empty
EntityMatchResult match_entity(EntityBase *entity) const;
bool method_equals(const char *str) const {
return method && method_len == strlen(str) && memcmp(method, str, method_len) == 0;
}
bool method_empty() const { return method_len == 0; }
};
#ifdef USE_WEBSERVER_SORTING

View File

@@ -6,6 +6,29 @@
namespace esphome {
namespace web_server {
// Write HTML-escaped text to stream (escapes ", &, <, >)
static void write_html_escaped(AsyncResponseStream *stream, const char *text) {
for (const char *p = text; *p; ++p) {
switch (*p) {
case '"':
stream->print("&quot;");
break;
case '&':
stream->print("&amp;");
break;
case '<':
stream->print("&lt;");
break;
case '>':
stream->print("&gt;");
break;
default:
stream->write(*p);
break;
}
}
}
void write_row(AsyncResponseStream *stream, EntityBase *obj, const std::string &klass, const std::string &action,
const std::function<void(AsyncResponseStream &stream, EntityBase *obj)> &action_func = nullptr) {
stream->print("<tr class=\"");
@@ -17,8 +40,27 @@ void write_row(AsyncResponseStream *stream, EntityBase *obj, const std::string &
stream->print("-");
char object_id_buf[OBJECT_ID_MAX_LEN];
stream->print(obj->get_object_id_to(object_id_buf).c_str());
// Add data attributes for hierarchical URL support
stream->print("\" data-domain=\"");
stream->print(klass.c_str());
stream->print("\" data-name=\"");
write_html_escaped(stream, obj->get_name().c_str());
#ifdef USE_DEVICES
Device *device = obj->get_device();
if (device != nullptr) {
stream->print("\" data-device=\"");
write_html_escaped(stream, device->get_name());
}
#endif
stream->print("\"><td>");
stream->print(obj->get_name().c_str());
#ifdef USE_DEVICES
if (device != nullptr) {
stream->print("[");
write_html_escaped(stream, device->get_name());
stream->print("] ");
}
#endif
write_html_escaped(stream, obj->get_name().c_str());
stream->print("</td><td></td><td>");
stream->print(action.c_str());
if (action_func) {

View File

@@ -13,7 +13,8 @@ namespace web_server_idf {
static const char *const TAG = "web_server_idf_utils";
void url_decode(char *str) {
size_t url_decode(char *str) {
char *start = str;
char *ptr = str, buf;
for (; *str; str++, ptr++) {
if (*str == '%') {
@@ -31,7 +32,8 @@ void url_decode(char *str) {
*ptr = *str;
}
}
*ptr = *str;
*ptr = '\0';
return ptr - start;
}
bool request_has_header(httpd_req_t *req, const char *name) { return httpd_req_get_hdr_value_len(req, name); }

View File

@@ -8,6 +8,10 @@
namespace esphome {
namespace web_server_idf {
/// Decode URL-encoded string in-place (e.g., %20 -> space, + -> space)
/// Returns the new length of the decoded string
size_t url_decode(char *str);
bool request_has_header(httpd_req_t *req, const char *name);
optional<std::string> request_get_header(httpd_req_t *req, const char *name);
optional<std::string> request_get_url_query(httpd_req_t *req);

View File

@@ -247,11 +247,20 @@ optional<std::string> AsyncWebServerRequest::get_header(const char *name) const
}
std::string AsyncWebServerRequest::url() const {
auto *str = strchr(this->req_->uri, '?');
if (str == nullptr) {
return this->req_->uri;
auto *query_start = strchr(this->req_->uri, '?');
std::string result;
if (query_start == nullptr) {
result = this->req_->uri;
} else {
result = std::string(this->req_->uri, query_start - this->req_->uri);
}
return std::string(this->req_->uri, str - this->req_->uri);
// Decode URL-encoded characters in-place (e.g., %20 -> space)
// This matches AsyncWebServer behavior on Arduino
if (!result.empty()) {
size_t new_len = url_decode(&result[0]);
result.resize(new_len);
}
return result;
}
std::string AsyncWebServerRequest::host() const { return this->get_header("Host").value(); }

View File

@@ -1972,6 +1972,26 @@ MQTT_COMMAND_COMPONENT_SCHEMA = MQTT_COMPONENT_SCHEMA.extend(
)
def _validate_no_slash(value):
"""Validate that a name does not contain '/' characters.
The '/' character is used as a path separator in web server URLs,
so it cannot be used in entity or device names.
"""
if "/" in value:
raise Invalid(
f"Name cannot contain '/' character (used as URL path separator): {value}"
)
return value
# Maximum length for entity, device, and area names
# This ensures web server URL IDs fit in a 280-byte buffer:
# domain(20) + "/" + device(120) + "/" + name(120) + null = 263 bytes
# Note: Must be < 255 because web_server UrlMatch uses uint8_t for length fields
NAME_MAX_LENGTH = 120
def _validate_entity_name(value):
value = string(value)
try:
@@ -1982,9 +2002,28 @@ def _validate_entity_name(value):
requires_friendly_name(
"Name cannot be None when esphome->friendly_name is not set!"
)(value)
if value is not None:
# Validate length for web server URL compatibility
if len(value) > NAME_MAX_LENGTH:
raise Invalid(
f"Name is too long ({len(value)} chars). "
f"Maximum length is {NAME_MAX_LENGTH} characters."
)
# Validate no '/' in name for web server URL compatibility
_validate_no_slash(value)
return value
def string_no_slash(value):
"""Validate a string that cannot contain '/' characters.
Used for device and area names where '/' is reserved as a URL path separator.
Use with cv.Length() to also enforce maximum length.
"""
value = string(value)
return _validate_no_slash(value)
ENTITY_BASE_SCHEMA = Schema(
{
Optional(CONF_NAME): _validate_entity_name,

View File

@@ -186,14 +186,14 @@ else:
AREA_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_ID): cv.declare_id(Area),
cv.Required(CONF_NAME): cv.string,
cv.Required(CONF_NAME): cv.All(cv.string_no_slash, cv.Length(max=120)),
}
)
DEVICE_SCHEMA = cv.Schema(
{
cv.GenerateID(CONF_ID): cv.declare_id(Device),
cv.Required(CONF_NAME): cv.string,
cv.Required(CONF_NAME): cv.All(cv.string_no_slash, cv.Length(max=120)),
cv.Optional(CONF_AREA_ID): cv.use_id(Area),
}
)
@@ -207,7 +207,9 @@ CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.Required(CONF_NAME): cv.valid_name,
cv.Optional(CONF_FRIENDLY_NAME, ""): cv.All(cv.string, cv.Length(max=120)),
cv.Optional(CONF_FRIENDLY_NAME, ""): cv.All(
cv.string_no_slash, cv.Length(max=120)
),
cv.Optional(CONF_AREA): validate_area_config,
cv.Optional(CONF_COMMENT): cv.All(cv.string, cv.Length(max=255)),
cv.Required(CONF_BUILD_PATH): cv.string,

View File

@@ -91,6 +91,8 @@ class EntityBase {
return this->device_->get_device_id();
}
void set_device(Device *device) { this->device_ = device; }
// Get the device this entity belongs to (nullptr if main device)
Device *get_device() const { return this->device_; }
#endif
// Check if this entity has state

View File

@@ -502,3 +502,60 @@ def test_only_with_user_value_overrides_default() -> None:
result = schema({"mqtt_id": "custom_id"})
assert result.get("mqtt_id") == "custom_id"
@pytest.mark.parametrize("value", ("hello", "Hello World", "test_name", "温度"))
def test_string_no_slash__valid(value: str) -> None:
actual = config_validation.string_no_slash(value)
assert actual == value
@pytest.mark.parametrize("value", ("has/slash", "a/b/c", "/leading", "trailing/"))
def test_string_no_slash__slash_rejected(value: str) -> None:
with pytest.raises(Invalid, match="cannot contain '/' character"):
config_validation.string_no_slash(value)
def test_string_no_slash__long_string_allowed() -> None:
# string_no_slash doesn't enforce length - use cv.Length() separately
long_value = "x" * 200
assert config_validation.string_no_slash(long_value) == long_value
def test_string_no_slash__empty() -> None:
assert config_validation.string_no_slash("") == ""
@pytest.mark.parametrize("value", ("Temperature", "Living Room Light", "温度传感器"))
def test_validate_entity_name__valid(value: str) -> None:
actual = config_validation._validate_entity_name(value)
assert actual == value
def test_validate_entity_name__slash_rejected() -> None:
with pytest.raises(Invalid, match="cannot contain '/' character"):
config_validation._validate_entity_name("has/slash")
def test_validate_entity_name__max_length() -> None:
# 120 chars should pass
assert config_validation._validate_entity_name("x" * 120) == "x" * 120
# 121 chars should fail
with pytest.raises(Invalid, match="too long.*121 chars.*Maximum.*120"):
config_validation._validate_entity_name("x" * 121)
def test_validate_entity_name__none_without_friendly_name() -> None:
# When name is "None" and friendly_name is not set, it should fail
CORE.friendly_name = None
with pytest.raises(Invalid, match="friendly_name is not set"):
config_validation._validate_entity_name("None")
def test_validate_entity_name__none_with_friendly_name() -> None:
# When name is "None" but friendly_name is set, it should return None
CORE.friendly_name = "My Device"
result = config_validation._validate_entity_name("None")
assert result is None
CORE.friendly_name = None # Reset