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:
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(""");
|
||||
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,
|
||||
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) {
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(); }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user