mirror of
https://github.com/esphome/esphome.git
synced 2026-02-08 00:31:58 +00:00
[web_server] Fix URL collisions with UTF-8 names and sub-devices (#12627)
This commit is contained in:
@@ -45,62 +45,144 @@ static constexpr size_t PSTR_LOCAL_SIZE = 18;
|
|||||||
#define PSTR_LOCAL(mode_s) ESPHOME_strncpy_P(buf, (ESPHOME_PGM_P) ((mode_s)), PSTR_LOCAL_SIZE - 1)
|
#define PSTR_LOCAL(mode_s) ESPHOME_strncpy_P(buf, (ESPHOME_PGM_P) ((mode_s)), PSTR_LOCAL_SIZE - 1)
|
||||||
|
|
||||||
// Parse URL and return match info
|
// Parse URL and return match info
|
||||||
static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain) {
|
// URL formats (disambiguated by HTTP method for 3-segment case):
|
||||||
UrlMatch match{};
|
// GET /{domain}/{entity_name} - main device state
|
||||||
|
// POST /{domain}/{entity_name}/{action} - main device action
|
||||||
|
// 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) {
|
||||||
|
// URL must start with '/' and have content after it
|
||||||
|
if (url_len < 2 || url_ptr[0] != '/')
|
||||||
|
return UrlMatch{};
|
||||||
|
|
||||||
// URL must start with '/'
|
const char *p = url_ptr + 1;
|
||||||
if (url_len < 2 || url_ptr[0] != '/') {
|
|
||||||
return match;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip leading '/'
|
|
||||||
const char *start = url_ptr + 1;
|
|
||||||
const char *end = url_ptr + url_len;
|
const char *end = url_ptr + url_len;
|
||||||
|
|
||||||
// Find domain (everything up to next '/' or end)
|
// Helper to find next segment: returns pointer after '/' or nullptr if no more slashes
|
||||||
const char *domain_end = (const char *) memchr(start, '/', end - start);
|
auto next_segment = [&end](const char *start) -> const char * {
|
||||||
if (!domain_end) {
|
const char *slash = (const char *) memchr(start, '/', end - start);
|
||||||
// No second slash found - original behavior returns invalid
|
return slash ? slash + 1 : nullptr;
|
||||||
return match;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
// Set domain
|
// Helper to make StringRef from segment start to next segment (or end)
|
||||||
match.domain = start;
|
auto make_ref = [&end](const char *start, const char *next_start) -> StringRef {
|
||||||
match.domain_len = domain_end - start;
|
return StringRef(start, (next_start ? next_start - 1 : end) - start);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse domain segment
|
||||||
|
const char *s1 = p;
|
||||||
|
const char *s2 = next_segment(s1);
|
||||||
|
|
||||||
|
// Must have domain with trailing slash
|
||||||
|
if (!s2)
|
||||||
|
return UrlMatch{};
|
||||||
|
|
||||||
|
UrlMatch match{};
|
||||||
|
match.domain = make_ref(s1, s2);
|
||||||
match.valid = true;
|
match.valid = true;
|
||||||
|
|
||||||
if (only_domain) {
|
if (only_domain || s2 >= end)
|
||||||
return match;
|
return match;
|
||||||
}
|
|
||||||
|
|
||||||
// Parse ID if present
|
// Parse remaining segments only when needed
|
||||||
if (domain_end + 1 >= end) {
|
const char *s3 = next_segment(s2);
|
||||||
return match; // Nothing after domain slash
|
const char *s4 = s3 ? next_segment(s3) : nullptr;
|
||||||
}
|
|
||||||
|
|
||||||
const char *id_start = domain_end + 1;
|
StringRef seg2 = make_ref(s2, s3);
|
||||||
const char *id_end = (const char *) memchr(id_start, '/', end - id_start);
|
StringRef seg3 = s3 ? make_ref(s3, s4) : StringRef();
|
||||||
|
StringRef seg4 = s4 ? make_ref(s4, nullptr) : StringRef();
|
||||||
|
|
||||||
if (!id_end) {
|
// Reject empty segments
|
||||||
// No more slashes, entire remaining string is ID
|
if (seg2.empty() || (s3 && seg3.empty()) || (s4 && seg4.empty()))
|
||||||
match.id = id_start;
|
return UrlMatch{};
|
||||||
match.id_len = end - id_start;
|
|
||||||
return match;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set ID
|
// Interpret based on segment count
|
||||||
match.id = id_start;
|
if (!s3) {
|
||||||
match.id_len = id_end - id_start;
|
// 1 segment after domain: /{domain}/{entity}
|
||||||
|
match.id = seg2;
|
||||||
// Parse method if present
|
} else if (!s4) {
|
||||||
if (id_end + 1 < end) {
|
// 2 segments after domain: /{domain}/{X}/{Y}
|
||||||
match.method = id_end + 1;
|
// HTTP method disambiguates: GET = device/entity, POST = entity/action
|
||||||
match.method_len = end - (id_end + 1);
|
if (is_post) {
|
||||||
|
match.id = seg2;
|
||||||
|
match.method = seg3;
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
#ifdef USE_DEVICES
|
||||||
|
match.device_name = seg2;
|
||||||
|
match.id = seg3;
|
||||||
|
#else
|
||||||
|
return UrlMatch{}; // 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)
|
||||||
|
}
|
||||||
|
match.device_name = seg2;
|
||||||
|
match.id = seg3;
|
||||||
|
match.method = seg4;
|
||||||
|
#else
|
||||||
|
return UrlMatch{}; // Not supported without USE_DEVICES
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
return match;
|
return match;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
EntityMatchResult UrlMatch::match_entity(EntityBase *entity) const {
|
||||||
|
EntityMatchResult result{false, this->method.empty()};
|
||||||
|
|
||||||
|
#ifdef USE_DEVICES
|
||||||
|
Device *entity_device = entity->get_device();
|
||||||
|
bool url_has_device = !this->device_name.empty();
|
||||||
|
bool entity_has_device = (entity_device != nullptr);
|
||||||
|
|
||||||
|
// Device matching: URL device segment must match entity's device
|
||||||
|
if (url_has_device != entity_has_device) {
|
||||||
|
return result; // Mismatch: one has device, other doesn't
|
||||||
|
}
|
||||||
|
if (url_has_device && this->device_name != entity_device->get_name()) {
|
||||||
|
return result; // Device name doesn't match
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Try matching by entity name (new format)
|
||||||
|
if (this->id == entity->get_name()) {
|
||||||
|
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 == object_id) {
|
||||||
|
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.",
|
||||||
|
(int) this->domain.size(), this->domain.c_str(), (int) this->device_name.size(),
|
||||||
|
this->device_name.c_str(), (int) this->id.size(), this->id.c_str(), (int) this->domain.size(),
|
||||||
|
this->domain.c_str(), 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.",
|
||||||
|
(int) this->domain.size(), this->domain.c_str(), (int) this->id.size(), this->id.c_str(),
|
||||||
|
(int) this->domain.size(), this->domain.c_str(), entity->get_name().c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
#if !defined(USE_ESP32) && defined(USE_ARDUINO)
|
#if !defined(USE_ESP32) && defined(USE_ARDUINO)
|
||||||
// helper for allowing only unique entries in the queue
|
// helper for allowing only unique entries in the queue
|
||||||
void DeferredUpdateEventSource::deq_push_back_with_dedup_(void *source, message_generator_t *message_generator) {
|
void DeferredUpdateEventSource::deq_push_back_with_dedup_(void *source, message_generator_t *message_generator) {
|
||||||
@@ -397,15 +479,53 @@ void WebServer::handle_js_request(AsyncWebServerRequest *request) {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Helper functions to reduce code size by avoiding macro expansion
|
// 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) {
|
||||||
char id_buf[160]; // prefix + dash + object_id (up to 128) + null
|
const StringRef &name = obj->get_name();
|
||||||
size_t len = strlen(prefix);
|
size_t prefix_len = strlen(prefix);
|
||||||
memcpy(id_buf, prefix, len); // NOLINT(bugprone-not-null-terminated-result) - null added by write_object_id_to
|
size_t name_len = name.size();
|
||||||
id_buf[len++] = '-';
|
|
||||||
obj->write_object_id_to(id_buf + len, sizeof(id_buf) - len);
|
#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;
|
root[ESPHOME_F("id")] = id_buf;
|
||||||
|
|
||||||
if (start_config == DETAIL_ALL) {
|
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("icon")] = obj->get_icon_ref();
|
||||||
root[ESPHOME_F("entity_category")] = obj->get_entity_category();
|
root[ESPHOME_F("entity_category")] = obj->get_entity_category();
|
||||||
bool is_disabled = obj->is_disabled_by_default();
|
bool is_disabled = obj->is_disabled_by_default();
|
||||||
@@ -444,10 +564,11 @@ void WebServer::on_sensor_update(sensor::Sensor *obj) {
|
|||||||
}
|
}
|
||||||
void WebServer::handle_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
void WebServer::handle_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||||
for (sensor::Sensor *obj : App.get_sensors()) {
|
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;
|
continue;
|
||||||
// Note: request->method() is always HTTP_GET here (canHandle ensures this)
|
// 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);
|
auto detail = get_request_detail(request);
|
||||||
std::string data = this->sensor_json_(obj, obj->state, detail);
|
std::string data = this->sensor_json_(obj, obj->state, detail);
|
||||||
request->send(200, "application/json", data.c_str());
|
request->send(200, "application/json", data.c_str());
|
||||||
@@ -490,10 +611,11 @@ void WebServer::on_text_sensor_update(text_sensor::TextSensor *obj) {
|
|||||||
}
|
}
|
||||||
void WebServer::handle_text_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
void WebServer::handle_text_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||||
for (text_sensor::TextSensor *obj : App.get_text_sensors()) {
|
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;
|
continue;
|
||||||
// Note: request->method() is always HTTP_GET here (canHandle ensures this)
|
// 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);
|
auto detail = get_request_detail(request);
|
||||||
std::string data = this->text_sensor_json_(obj, obj->state, detail);
|
std::string data = this->text_sensor_json_(obj, obj->state, detail);
|
||||||
request->send(200, "application/json", data.c_str());
|
request->send(200, "application/json", data.c_str());
|
||||||
@@ -532,10 +654,11 @@ void WebServer::on_switch_update(switch_::Switch *obj) {
|
|||||||
}
|
}
|
||||||
void WebServer::handle_switch_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
void WebServer::handle_switch_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||||
for (switch_::Switch *obj : App.get_switches()) {
|
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;
|
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);
|
auto detail = get_request_detail(request);
|
||||||
std::string data = this->switch_json_(obj, obj->state, detail);
|
std::string data = this->switch_json_(obj, obj->state, detail);
|
||||||
request->send(200, "application/json", data.c_str());
|
request->send(200, "application/json", data.c_str());
|
||||||
@@ -601,9 +724,10 @@ std::string WebServer::switch_json_(switch_::Switch *obj, bool value, JsonDetail
|
|||||||
#ifdef USE_BUTTON
|
#ifdef USE_BUTTON
|
||||||
void WebServer::handle_button_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
void WebServer::handle_button_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||||
for (button::Button *obj : App.get_buttons()) {
|
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;
|
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);
|
auto detail = get_request_detail(request);
|
||||||
std::string data = this->button_json_(obj, detail);
|
std::string data = this->button_json_(obj, detail);
|
||||||
request->send(200, "application/json", data.c_str());
|
request->send(200, "application/json", data.c_str());
|
||||||
@@ -645,10 +769,11 @@ void WebServer::on_binary_sensor_update(binary_sensor::BinarySensor *obj) {
|
|||||||
}
|
}
|
||||||
void WebServer::handle_binary_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
void WebServer::handle_binary_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||||
for (binary_sensor::BinarySensor *obj : App.get_binary_sensors()) {
|
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;
|
continue;
|
||||||
// Note: request->method() is always HTTP_GET here (canHandle ensures this)
|
// 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);
|
auto detail = get_request_detail(request);
|
||||||
std::string data = this->binary_sensor_json_(obj, obj->state, detail);
|
std::string data = this->binary_sensor_json_(obj, obj->state, detail);
|
||||||
request->send(200, "application/json", data.c_str());
|
request->send(200, "application/json", data.c_str());
|
||||||
@@ -686,10 +811,11 @@ void WebServer::on_fan_update(fan::Fan *obj) {
|
|||||||
}
|
}
|
||||||
void WebServer::handle_fan_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
void WebServer::handle_fan_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||||
for (fan::Fan *obj : App.get_fans()) {
|
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;
|
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);
|
auto detail = get_request_detail(request);
|
||||||
std::string data = this->fan_json_(obj, detail);
|
std::string data = this->fan_json_(obj, detail);
|
||||||
request->send(200, "application/json", data.c_str());
|
request->send(200, "application/json", data.c_str());
|
||||||
@@ -766,10 +892,11 @@ void WebServer::on_light_update(light::LightState *obj) {
|
|||||||
}
|
}
|
||||||
void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||||
for (light::LightState *obj : App.get_lights()) {
|
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;
|
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);
|
auto detail = get_request_detail(request);
|
||||||
std::string data = this->light_json_(obj, detail);
|
std::string data = this->light_json_(obj, detail);
|
||||||
request->send(200, "application/json", data.c_str());
|
request->send(200, "application/json", data.c_str());
|
||||||
@@ -844,10 +971,11 @@ void WebServer::on_cover_update(cover::Cover *obj) {
|
|||||||
}
|
}
|
||||||
void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||||
for (cover::Cover *obj : App.get_covers()) {
|
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;
|
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);
|
auto detail = get_request_detail(request);
|
||||||
std::string data = this->cover_json_(obj, detail);
|
std::string data = this->cover_json_(obj, detail);
|
||||||
request->send(200, "application/json", data.c_str());
|
request->send(200, "application/json", data.c_str());
|
||||||
@@ -932,10 +1060,11 @@ void WebServer::on_number_update(number::Number *obj) {
|
|||||||
}
|
}
|
||||||
void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||||
for (auto *obj : App.get_numbers()) {
|
for (auto *obj : App.get_numbers()) {
|
||||||
if (!match.id_equals_entity(obj))
|
auto entity_match = match.match_entity(obj);
|
||||||
|
if (!entity_match.matched)
|
||||||
continue;
|
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);
|
auto detail = get_request_detail(request);
|
||||||
std::string data = this->number_json_(obj, obj->state, detail);
|
std::string data = this->number_json_(obj, obj->state, detail);
|
||||||
request->send(200, "application/json", data.c_str());
|
request->send(200, "application/json", data.c_str());
|
||||||
@@ -999,9 +1128,10 @@ void WebServer::on_date_update(datetime::DateEntity *obj) {
|
|||||||
}
|
}
|
||||||
void WebServer::handle_date_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
void WebServer::handle_date_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||||
for (auto *obj : App.get_dates()) {
|
for (auto *obj : App.get_dates()) {
|
||||||
if (!match.id_equals_entity(obj))
|
auto entity_match = match.match_entity(obj);
|
||||||
|
if (!entity_match.matched)
|
||||||
continue;
|
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);
|
auto detail = get_request_detail(request);
|
||||||
std::string data = this->date_json_(obj, detail);
|
std::string data = this->date_json_(obj, detail);
|
||||||
request->send(200, "application/json", data.c_str());
|
request->send(200, "application/json", data.c_str());
|
||||||
@@ -1062,9 +1192,10 @@ void WebServer::on_time_update(datetime::TimeEntity *obj) {
|
|||||||
}
|
}
|
||||||
void WebServer::handle_time_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
void WebServer::handle_time_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||||
for (auto *obj : App.get_times()) {
|
for (auto *obj : App.get_times()) {
|
||||||
if (!match.id_equals_entity(obj))
|
auto entity_match = match.match_entity(obj);
|
||||||
|
if (!entity_match.matched)
|
||||||
continue;
|
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);
|
auto detail = get_request_detail(request);
|
||||||
std::string data = this->time_json_(obj, detail);
|
std::string data = this->time_json_(obj, detail);
|
||||||
request->send(200, "application/json", data.c_str());
|
request->send(200, "application/json", data.c_str());
|
||||||
@@ -1124,9 +1255,10 @@ void WebServer::on_datetime_update(datetime::DateTimeEntity *obj) {
|
|||||||
}
|
}
|
||||||
void WebServer::handle_datetime_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
void WebServer::handle_datetime_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||||
for (auto *obj : App.get_datetimes()) {
|
for (auto *obj : App.get_datetimes()) {
|
||||||
if (!match.id_equals_entity(obj))
|
auto entity_match = match.match_entity(obj);
|
||||||
|
if (!entity_match.matched)
|
||||||
continue;
|
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);
|
auto detail = get_request_detail(request);
|
||||||
std::string data = this->datetime_json_(obj, detail);
|
std::string data = this->datetime_json_(obj, detail);
|
||||||
request->send(200, "application/json", data.c_str());
|
request->send(200, "application/json", data.c_str());
|
||||||
@@ -1188,10 +1320,11 @@ void WebServer::on_text_update(text::Text *obj) {
|
|||||||
}
|
}
|
||||||
void WebServer::handle_text_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
void WebServer::handle_text_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||||
for (auto *obj : App.get_texts()) {
|
for (auto *obj : App.get_texts()) {
|
||||||
if (!match.id_equals_entity(obj))
|
auto entity_match = match.match_entity(obj);
|
||||||
|
if (!entity_match.matched)
|
||||||
continue;
|
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);
|
auto detail = get_request_detail(request);
|
||||||
std::string data = this->text_json_(obj, obj->state, detail);
|
std::string data = this->text_json_(obj, obj->state, detail);
|
||||||
request->send(200, "application/json", data.c_str());
|
request->send(200, "application/json", data.c_str());
|
||||||
@@ -1244,10 +1377,11 @@ void WebServer::on_select_update(select::Select *obj) {
|
|||||||
}
|
}
|
||||||
void WebServer::handle_select_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
void WebServer::handle_select_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||||
for (auto *obj : App.get_selects()) {
|
for (auto *obj : App.get_selects()) {
|
||||||
if (!match.id_equals_entity(obj))
|
auto entity_match = match.match_entity(obj);
|
||||||
|
if (!entity_match.matched)
|
||||||
continue;
|
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);
|
auto detail = get_request_detail(request);
|
||||||
std::string data = this->select_json_(obj, obj->has_state() ? obj->current_option() : "", detail);
|
std::string data = this->select_json_(obj, obj->has_state() ? obj->current_option() : "", detail);
|
||||||
request->send(200, "application/json", data.c_str());
|
request->send(200, "application/json", data.c_str());
|
||||||
@@ -1301,10 +1435,11 @@ void WebServer::on_climate_update(climate::Climate *obj) {
|
|||||||
}
|
}
|
||||||
void WebServer::handle_climate_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
void WebServer::handle_climate_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||||
for (auto *obj : App.get_climates()) {
|
for (auto *obj : App.get_climates()) {
|
||||||
if (!match.id_equals_entity(obj))
|
auto entity_match = match.match_entity(obj);
|
||||||
|
if (!entity_match.matched)
|
||||||
continue;
|
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);
|
auto detail = get_request_detail(request);
|
||||||
std::string data = this->climate_json_(obj, detail);
|
std::string data = this->climate_json_(obj, detail);
|
||||||
request->send(200, "application/json", data.c_str());
|
request->send(200, "application/json", data.c_str());
|
||||||
@@ -1451,10 +1586,11 @@ void WebServer::on_lock_update(lock::Lock *obj) {
|
|||||||
}
|
}
|
||||||
void WebServer::handle_lock_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
void WebServer::handle_lock_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||||
for (lock::Lock *obj : App.get_locks()) {
|
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;
|
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);
|
auto detail = get_request_detail(request);
|
||||||
std::string data = this->lock_json_(obj, obj->state, detail);
|
std::string data = this->lock_json_(obj, obj->state, detail);
|
||||||
request->send(200, "application/json", data.c_str());
|
request->send(200, "application/json", data.c_str());
|
||||||
@@ -1525,10 +1661,11 @@ void WebServer::on_valve_update(valve::Valve *obj) {
|
|||||||
}
|
}
|
||||||
void WebServer::handle_valve_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
void WebServer::handle_valve_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||||
for (valve::Valve *obj : App.get_valves()) {
|
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;
|
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);
|
auto detail = get_request_detail(request);
|
||||||
std::string data = this->valve_json_(obj, detail);
|
std::string data = this->valve_json_(obj, detail);
|
||||||
request->send(200, "application/json", data.c_str());
|
request->send(200, "application/json", data.c_str());
|
||||||
@@ -1609,10 +1746,11 @@ void WebServer::on_alarm_control_panel_update(alarm_control_panel::AlarmControlP
|
|||||||
}
|
}
|
||||||
void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||||
for (alarm_control_panel::AlarmControlPanel *obj : App.get_alarm_control_panels()) {
|
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;
|
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);
|
auto detail = get_request_detail(request);
|
||||||
std::string data = this->alarm_control_panel_json_(obj, obj->get_state(), detail);
|
std::string data = this->alarm_control_panel_json_(obj, obj->get_state(), detail);
|
||||||
request->send(200, "application/json", data.c_str());
|
request->send(200, "application/json", data.c_str());
|
||||||
@@ -1690,11 +1828,12 @@ void WebServer::on_event(event::Event *obj) {
|
|||||||
|
|
||||||
void WebServer::handle_event_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
void WebServer::handle_event_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||||
for (event::Event *obj : App.get_events()) {
|
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;
|
continue;
|
||||||
|
|
||||||
// Note: request->method() is always HTTP_GET here (canHandle ensures this)
|
// 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);
|
auto detail = get_request_detail(request);
|
||||||
std::string data = this->event_json_(obj, "", detail);
|
std::string data = this->event_json_(obj, "", detail);
|
||||||
request->send(200, "application/json", data.c_str());
|
request->send(200, "application/json", data.c_str());
|
||||||
@@ -1759,10 +1898,11 @@ void WebServer::on_update(update::UpdateEntity *obj) {
|
|||||||
}
|
}
|
||||||
void WebServer::handle_update_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
void WebServer::handle_update_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||||
for (update::UpdateEntity *obj : App.get_updates()) {
|
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;
|
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);
|
auto detail = get_request_detail(request);
|
||||||
std::string data = this->update_json_(obj, detail);
|
std::string data = this->update_json_(obj, detail);
|
||||||
request->send(200, "application/json", data.c_str());
|
request->send(200, "application/json", data.c_str());
|
||||||
@@ -1973,7 +2113,8 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Parse URL for component routing
|
// Parse URL for component routing
|
||||||
UrlMatch match = match_url(url.c_str(), url.length(), false);
|
// Pass HTTP method to disambiguate 3-segment URLs (GET=sub-device state, POST=main device action)
|
||||||
|
UrlMatch match = match_url(url.c_str(), url.length(), false, request->method() == HTTP_POST);
|
||||||
|
|
||||||
// Route to appropriate handler based on domain
|
// Route to appropriate handler based on domain
|
||||||
// NOLINTNEXTLINE(readability-simplify-boolean-expr)
|
// NOLINTNEXTLINE(readability-simplify-boolean-expr)
|
||||||
|
|||||||
@@ -35,33 +35,29 @@ extern const size_t ESPHOME_WEBSERVER_JS_INCLUDE_SIZE;
|
|||||||
|
|
||||||
namespace esphome::web_server {
|
namespace esphome::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/method segment in URL
|
||||||
|
};
|
||||||
|
|
||||||
/// Internal helper struct that is used to parse incoming URLs
|
/// Internal helper struct that is used to parse incoming URLs
|
||||||
struct UrlMatch {
|
struct UrlMatch {
|
||||||
const char *domain; ///< Pointer to domain within URL, for example "sensor"
|
StringRef domain; ///< Domain within URL, for example "sensor"
|
||||||
const char *id; ///< Pointer to id within URL, for example "living_room_fan"
|
StringRef id; ///< Entity name/id within URL, for example "Temperature"
|
||||||
const char *method; ///< Pointer to method within URL, for example "turn_on"
|
StringRef method; ///< Method within URL, for example "turn_on"
|
||||||
uint8_t domain_len; ///< Length of domain string
|
#ifdef USE_DEVICES
|
||||||
uint8_t id_len; ///< Length of id string
|
StringRef device_name; ///< Device name within URL, empty for main device
|
||||||
uint8_t method_len; ///< Length of method string
|
#endif
|
||||||
bool valid; ///< Whether this match is valid
|
bool valid{false}; ///< Whether this match is valid
|
||||||
|
|
||||||
// Helper methods for string comparisons
|
// Helper methods for string comparisons
|
||||||
bool domain_equals(const char *str) const {
|
bool domain_equals(const char *str) const { return this->domain == str; }
|
||||||
return domain && domain_len == strlen(str) && memcmp(domain, str, domain_len) == 0;
|
bool method_equals(const char *str) const { return this->method == str; }
|
||||||
}
|
|
||||||
|
|
||||||
bool id_equals_entity(EntityBase *entity) const {
|
/// Match entity by name first, then fall back to object_id with deprecation warning
|
||||||
// Get object_id with zero heap allocation
|
/// Returns EntityMatchResult with match status and whether action segment is empty
|
||||||
char object_id_buf[OBJECT_ID_MAX_LEN];
|
EntityMatchResult match_entity(EntityBase *entity) const;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
#ifdef USE_WEBSERVER_SORTING
|
||||||
|
|||||||
@@ -5,6 +5,29 @@
|
|||||||
|
|
||||||
namespace esphome::web_server {
|
namespace esphome::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(""");
|
||||||
|
break;
|
||||||
|
case '&':
|
||||||
|
stream->print("&");
|
||||||
|
break;
|
||||||
|
case '<':
|
||||||
|
stream->print("<");
|
||||||
|
break;
|
||||||
|
case '>':
|
||||||
|
stream->print(">");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
stream->write(*p);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void write_row(AsyncResponseStream *stream, EntityBase *obj, const std::string &klass, const std::string &action,
|
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) {
|
const std::function<void(AsyncResponseStream &stream, EntityBase *obj)> &action_func = nullptr) {
|
||||||
stream->print("<tr class=\"");
|
stream->print("<tr class=\"");
|
||||||
@@ -16,8 +39,27 @@ void write_row(AsyncResponseStream *stream, EntityBase *obj, const std::string &
|
|||||||
stream->print("-");
|
stream->print("-");
|
||||||
char object_id_buf[OBJECT_ID_MAX_LEN];
|
char object_id_buf[OBJECT_ID_MAX_LEN];
|
||||||
stream->print(obj->get_object_id_to(object_id_buf).c_str());
|
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("\"><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("</td><td></td><td>");
|
||||||
stream->print(action.c_str());
|
stream->print(action.c_str());
|
||||||
if (action_func) {
|
if (action_func) {
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ namespace web_server_idf {
|
|||||||
|
|
||||||
static const char *const TAG = "web_server_idf_utils";
|
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;
|
char *ptr = str, buf;
|
||||||
for (; *str; str++, ptr++) {
|
for (; *str; str++, ptr++) {
|
||||||
if (*str == '%') {
|
if (*str == '%') {
|
||||||
@@ -31,7 +32,8 @@ void url_decode(char *str) {
|
|||||||
*ptr = *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); }
|
bool request_has_header(httpd_req_t *req, const char *name) { return httpd_req_get_hdr_value_len(req, name); }
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
namespace esphome {
|
namespace esphome {
|
||||||
namespace web_server_idf {
|
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);
|
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_header(httpd_req_t *req, const char *name);
|
||||||
optional<std::string> request_get_url_query(httpd_req_t *req);
|
optional<std::string> request_get_url_query(httpd_req_t *req);
|
||||||
|
|||||||
@@ -247,11 +247,20 @@ optional<std::string> AsyncWebServerRequest::get_header(const char *name) const
|
|||||||
}
|
}
|
||||||
|
|
||||||
std::string AsyncWebServerRequest::url() const {
|
std::string AsyncWebServerRequest::url() const {
|
||||||
auto *str = strchr(this->req_->uri, '?');
|
auto *query_start = strchr(this->req_->uri, '?');
|
||||||
if (str == nullptr) {
|
std::string result;
|
||||||
return this->req_->uri;
|
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(); }
|
std::string AsyncWebServerRequest::host() const { return this->get_header("Host").value(); }
|
||||||
|
|||||||
@@ -1981,6 +1981,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):
|
def _validate_entity_name(value):
|
||||||
value = string(value)
|
value = string(value)
|
||||||
try:
|
try:
|
||||||
@@ -1991,9 +2011,28 @@ def _validate_entity_name(value):
|
|||||||
requires_friendly_name(
|
requires_friendly_name(
|
||||||
"Name cannot be None when esphome->friendly_name is not set!"
|
"Name cannot be None when esphome->friendly_name is not set!"
|
||||||
)(value)
|
)(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
|
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(
|
ENTITY_BASE_SCHEMA = Schema(
|
||||||
{
|
{
|
||||||
Optional(CONF_NAME): _validate_entity_name,
|
Optional(CONF_NAME): _validate_entity_name,
|
||||||
|
|||||||
@@ -186,14 +186,14 @@ else:
|
|||||||
AREA_SCHEMA = cv.Schema(
|
AREA_SCHEMA = cv.Schema(
|
||||||
{
|
{
|
||||||
cv.GenerateID(CONF_ID): cv.declare_id(Area),
|
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(
|
DEVICE_SCHEMA = cv.Schema(
|
||||||
{
|
{
|
||||||
cv.GenerateID(CONF_ID): cv.declare_id(Device),
|
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),
|
cv.Optional(CONF_AREA_ID): cv.use_id(Area),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -207,7 +207,9 @@ CONFIG_SCHEMA = cv.All(
|
|||||||
cv.Schema(
|
cv.Schema(
|
||||||
{
|
{
|
||||||
cv.Required(CONF_NAME): cv.valid_name,
|
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_AREA): validate_area_config,
|
||||||
cv.Optional(CONF_COMMENT): cv.All(cv.string, cv.Length(max=255)),
|
cv.Optional(CONF_COMMENT): cv.All(cv.string, cv.Length(max=255)),
|
||||||
cv.Required(CONF_BUILD_PATH): cv.string,
|
cv.Required(CONF_BUILD_PATH): cv.string,
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ class EntityBase {
|
|||||||
return this->device_->get_device_id();
|
return this->device_->get_device_id();
|
||||||
}
|
}
|
||||||
void set_device(Device *device) { this->device_ = device; }
|
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
|
#endif
|
||||||
|
|
||||||
// Check if this entity has state
|
// Check if this entity has state
|
||||||
|
|||||||
@@ -502,3 +502,60 @@ def test_only_with_user_value_overrides_default() -> None:
|
|||||||
|
|
||||||
result = schema({"mqtt_id": "custom_id"})
|
result = schema({"mqtt_id": "custom_id"})
|
||||||
assert result.get("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
|
||||||
|
|||||||
Reference in New Issue
Block a user