From 3c0f43db9e9ebccb713efa35880966e9d5721821 Mon Sep 17 00:00:00 2001 From: polyfloyd Date: Wed, 21 Jan 2026 00:58:47 +0100 Subject: [PATCH 1/2] Add the max_delta filter (#12605) Co-authored-by: clydebarrow <2366188+clydebarrow@users.noreply.github.com> --- esphome/components/sensor/__init__.py | 74 ++++--- esphome/components/sensor/filter.cpp | 33 ++-- esphome/components/sensor/filter.h | 14 +- tests/components/template/common-base.yaml | 2 + .../fixtures/sensor_filters_delta.yaml | 180 ++++++++++++++++++ .../integration/test_sensor_filters_delta.py | 163 ++++++++++++++++ 6 files changed, 420 insertions(+), 46 deletions(-) create mode 100644 tests/integration/fixtures/sensor_filters_delta.yaml create mode 100644 tests/integration/test_sensor_filters_delta.py diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 2ac45a55ac..ebbe0fbccc 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -9,6 +9,7 @@ from esphome.const import ( CONF_ABOVE, CONF_ACCURACY_DECIMALS, CONF_ALPHA, + CONF_BASELINE, CONF_BELOW, CONF_CALIBRATION, CONF_DEVICE_CLASS, @@ -38,7 +39,6 @@ from esphome.const import ( CONF_TIMEOUT, CONF_TO, CONF_TRIGGER_ID, - CONF_TYPE, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE, CONF_WEB_SERVER, @@ -107,7 +107,7 @@ from esphome.const import ( ) from esphome.core import CORE, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass from esphome.util import Registry CODEOWNERS = ["@esphome/core"] @@ -574,38 +574,56 @@ async def lambda_filter_to_code(config, filter_id): return automation.new_lambda_pvariable(filter_id, lambda_, StatelessLambdaFilter) -DELTA_SCHEMA = cv.Schema( - { - cv.Required(CONF_VALUE): cv.positive_float, - cv.Optional(CONF_TYPE, default="absolute"): cv.one_of( - "absolute", "percentage", lower=True - ), - } +def validate_delta_value(value): + if isinstance(value, str) and value.endswith("%"): + # Check it's a well-formed percentage, but return the string as-is + try: + cv.positive_float(value[:-1]) + return value + except cv.Invalid as exc: + raise cv.Invalid("Malformed delta % value") from exc + return cv.positive_float(value) + + +# This ideally would be done with `cv.maybe_simple_value` but it doesn't seem to respect the default for min_value. +DELTA_SCHEMA = cv.Any( + cv.All( + { + # Ideally this would be 'default=float("inf")' but it doesn't translate well to C++ + cv.Optional(CONF_MAX_VALUE): validate_delta_value, + cv.Optional(CONF_MIN_VALUE, default="0.0"): validate_delta_value, + cv.Optional(CONF_BASELINE): cv.templatable(cv.float_), + }, + cv.has_at_least_one_key(CONF_MAX_VALUE, CONF_MIN_VALUE), + ), + validate_delta_value, ) -def validate_delta(config): - try: - value = cv.positive_float(config) - return DELTA_SCHEMA({CONF_VALUE: value, CONF_TYPE: "absolute"}) - except cv.Invalid: - pass - try: - value = cv.percentage(config) - return DELTA_SCHEMA({CONF_VALUE: value, CONF_TYPE: "percentage"}) - except cv.Invalid: - pass - raise cv.Invalid("Delta filter requires a positive number or percentage value.") +def _get_delta(value): + if isinstance(value, str): + assert value.endswith("%") + return 0.0, float(value[:-1]) + return value, 0.0 -@FILTER_REGISTRY.register("delta", DeltaFilter, cv.Any(DELTA_SCHEMA, validate_delta)) +@FILTER_REGISTRY.register("delta", DeltaFilter, DELTA_SCHEMA) async def delta_filter_to_code(config, filter_id): - percentage = config[CONF_TYPE] == "percentage" - return cg.new_Pvariable( - filter_id, - config[CONF_VALUE], - percentage, - ) + # The config could be just the min_value, or it could be a dict. + max = MockObj("std::numeric_limits::infinity()"), 0 + if isinstance(config, dict): + min = _get_delta(config[CONF_MIN_VALUE]) + if CONF_MAX_VALUE in config: + max = _get_delta(config[CONF_MAX_VALUE]) + else: + min = _get_delta(config) + var = cg.new_Pvariable(filter_id, *min, *max) + if isinstance(config, dict) and (baseline_lambda := config.get(CONF_BASELINE)): + baseline = await cg.process_lambda( + baseline_lambda, [(float, "x")], return_type=float + ) + cg.add(var.set_baseline(baseline)) + return var @FILTER_REGISTRY.register("or", OrFilter, validate_filters) diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 8450ec4c4e..3adf28748d 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -291,22 +291,27 @@ optional ThrottleWithPriorityFilter::new_value(float value) { } // DeltaFilter -DeltaFilter::DeltaFilter(float delta, bool percentage_mode) - : delta_(delta), current_delta_(delta), last_value_(NAN), percentage_mode_(percentage_mode) {} +DeltaFilter::DeltaFilter(float min_a0, float min_a1, float max_a0, float max_a1) + : min_a0_(min_a0), min_a1_(min_a1), max_a0_(max_a0), max_a1_(max_a1) {} + +void DeltaFilter::set_baseline(float (*fn)(float)) { this->baseline_ = fn; } + optional DeltaFilter::new_value(float value) { - if (std::isnan(value)) { - if (std::isnan(this->last_value_)) { - return {}; - } else { - return this->last_value_ = value; - } + // Always yield the first value. + if (std::isnan(this->last_value_)) { + this->last_value_ = value; + return value; } - float diff = fabsf(value - this->last_value_); - if (std::isnan(this->last_value_) || (diff > 0.0f && diff >= this->current_delta_)) { - if (this->percentage_mode_) { - this->current_delta_ = fabsf(value * this->delta_); - } - return this->last_value_ = value; + // calculate min and max using the linear equation + float ref = this->baseline_(this->last_value_); + float min = fabsf(this->min_a0_ + ref * this->min_a1_); + float max = fabsf(this->max_a0_ + ref * this->max_a1_); + float delta = fabsf(value - ref); + // if there is no reference, e.g. for the first value, just accept this one, + // otherwise accept only if within range. + if (delta > min && delta <= max) { + this->last_value_ = value; + return value; } return {}; } diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 15c7656a7b..573b916a5d 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -452,15 +452,21 @@ class HeartbeatFilter : public Filter, public Component { class DeltaFilter : public Filter { public: - explicit DeltaFilter(float delta, bool percentage_mode); + explicit DeltaFilter(float min_a0, float min_a1, float max_a0, float max_a1); + + void set_baseline(float (*fn)(float)); optional new_value(float value) override; protected: - float delta_; - float current_delta_; + // These values represent linear equations for the min and max values but in practice only one of a0 and a1 will be + // non-zero Each limit is calculated as fabs(a0 + value * a1) + + float min_a0_, min_a1_, max_a0_, max_a1_; + // default baseline is the previous value + float (*baseline_)(float) = [](float last_value) { return last_value; }; + float last_value_{NAN}; - bool percentage_mode_; }; class OrFilter : public Filter { diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index 3b888c3d19..afc3fd9819 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -121,6 +121,8 @@ sensor: min_value: -10.0 - debounce: 0.1s - delta: 5.0 + - delta: + max_value: 2% - exponential_moving_average: alpha: 0.1 send_every: 15 diff --git a/tests/integration/fixtures/sensor_filters_delta.yaml b/tests/integration/fixtures/sensor_filters_delta.yaml new file mode 100644 index 0000000000..19bd2d5ca4 --- /dev/null +++ b/tests/integration/fixtures/sensor_filters_delta.yaml @@ -0,0 +1,180 @@ +esphome: + name: test-delta-filters + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: DEBUG + +sensor: + - platform: template + name: "Source Sensor 1" + id: source_sensor_1 + accuracy_decimals: 1 + + - platform: template + name: "Source Sensor 2" + id: source_sensor_2 + accuracy_decimals: 1 + + - platform: template + name: "Source Sensor 3" + id: source_sensor_3 + accuracy_decimals: 1 + + - platform: template + name: "Source Sensor 4" + id: source_sensor_4 + accuracy_decimals: 1 + + - platform: copy + source_id: source_sensor_1 + name: "Filter Min" + id: filter_min + filters: + - delta: + min_value: 10 + + - platform: copy + source_id: source_sensor_2 + name: "Filter Max" + id: filter_max + filters: + - delta: + max_value: 10 + + - platform: copy + source_id: source_sensor_3 + id: test_3_baseline + filters: + - median: + window_size: 6 + send_every: 1 + send_first_at: 1 + + - platform: copy + source_id: source_sensor_3 + name: "Filter Baseline Max" + id: filter_baseline_max + filters: + - delta: + max_value: 10 + baseline: !lambda return id(test_3_baseline).state; + + - platform: copy + source_id: source_sensor_4 + name: "Filter Zero Delta" + id: filter_zero_delta + filters: + - delta: 0 + +script: + - id: test_filter_min + then: + - sensor.template.publish: + id: source_sensor_1 + state: 1.0 + - delay: 20ms + - sensor.template.publish: + id: source_sensor_1 + state: 5.0 # Filtered out + - delay: 20ms + - sensor.template.publish: + id: source_sensor_1 + state: 12.0 + - delay: 20ms + - sensor.template.publish: + id: source_sensor_1 + state: 8.0 # Filtered out + - delay: 20ms + - sensor.template.publish: + id: source_sensor_1 + state: -2.0 + + - id: test_filter_max + then: + - sensor.template.publish: + id: source_sensor_2 + state: 1.0 + - delay: 20ms + - sensor.template.publish: + id: source_sensor_2 + state: 5.0 + - delay: 20ms + - sensor.template.publish: + id: source_sensor_2 + state: 40.0 # Filtered out + - delay: 20ms + - sensor.template.publish: + id: source_sensor_2 + state: 10.0 + - delay: 20ms + - sensor.template.publish: + id: source_sensor_2 + state: -40.0 # Filtered out + + - id: test_filter_baseline_max + then: + - sensor.template.publish: + id: source_sensor_3 + state: 1.0 + - delay: 20ms + - sensor.template.publish: + id: source_sensor_3 + state: 2.0 + - delay: 20ms + - sensor.template.publish: + id: source_sensor_3 + state: 3.0 + - delay: 20ms + - sensor.template.publish: + id: source_sensor_3 + state: 40.0 # Filtered out + - delay: 20ms + - sensor.template.publish: + id: source_sensor_3 + state: 20.0 # Filtered out + - delay: 20ms + - sensor.template.publish: + id: source_sensor_3 + state: 20.0 + + - id: test_filter_zero_delta + then: + - sensor.template.publish: + id: source_sensor_4 + state: 1.0 + - delay: 20ms + - sensor.template.publish: + id: source_sensor_4 + state: 1.0 # Filtered out + - delay: 20ms + - sensor.template.publish: + id: source_sensor_4 + state: 2.0 + +button: + - platform: template + name: "Test Filter Min" + id: btn_filter_min + on_press: + - script.execute: test_filter_min + + - platform: template + name: "Test Filter Max" + id: btn_filter_max + on_press: + - script.execute: test_filter_max + + - platform: template + name: "Test Filter Baseline Max" + id: btn_filter_baseline_max + on_press: + - script.execute: test_filter_baseline_max + + - platform: template + name: "Test Filter Zero Delta" + id: btn_filter_zero_delta + on_press: + - script.execute: test_filter_zero_delta diff --git a/tests/integration/test_sensor_filters_delta.py b/tests/integration/test_sensor_filters_delta.py new file mode 100644 index 0000000000..c7a26bf9d1 --- /dev/null +++ b/tests/integration/test_sensor_filters_delta.py @@ -0,0 +1,163 @@ +"""Test sensor DeltaFilter functionality.""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import ButtonInfo, EntityState, SensorState +import pytest + +from .state_utils import InitialStateHelper, build_key_to_entity_mapping +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_sensor_filters_delta( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + loop = asyncio.get_running_loop() + + sensor_values: dict[str, list[float]] = { + "filter_min": [], + "filter_max": [], + "filter_baseline_max": [], + "filter_zero_delta": [], + } + + filter_min_done = loop.create_future() + filter_max_done = loop.create_future() + filter_baseline_max_done = loop.create_future() + filter_zero_delta_done = loop.create_future() + + def on_state(state: EntityState) -> None: + if not isinstance(state, SensorState) or state.missing_state: + return + + sensor_name = key_to_sensor.get(state.key) + if sensor_name not in sensor_values: + return + + sensor_values[sensor_name].append(state.state) + + # Check completion conditions + if ( + sensor_name == "filter_min" + and len(sensor_values[sensor_name]) == 3 + and not filter_min_done.done() + ): + filter_min_done.set_result(True) + elif ( + sensor_name == "filter_max" + and len(sensor_values[sensor_name]) == 3 + and not filter_max_done.done() + ): + filter_max_done.set_result(True) + elif ( + sensor_name == "filter_baseline_max" + and len(sensor_values[sensor_name]) == 4 + and not filter_baseline_max_done.done() + ): + filter_baseline_max_done.set_result(True) + elif ( + sensor_name == "filter_zero_delta" + and len(sensor_values[sensor_name]) == 2 + and not filter_zero_delta_done.done() + ): + filter_zero_delta_done.set_result(True) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + # Get entities and build key mapping + entities, _ = await client.list_entities_services() + key_to_sensor = build_key_to_entity_mapping( + entities, + { + "filter_min": "Filter Min", + "filter_max": "Filter Max", + "filter_baseline_max": "Filter Baseline Max", + "filter_zero_delta": "Filter Zero Delta", + }, + ) + + # Set up initial state helper with all entities + initial_state_helper = InitialStateHelper(entities) + + # Subscribe to state changes with wrapper + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + + # Wait for initial states + await initial_state_helper.wait_for_initial_states() + + # Find all buttons + button_name_map = { + "Test Filter Min": "filter_min", + "Test Filter Max": "filter_max", + "Test Filter Baseline Max": "filter_baseline_max", + "Test Filter Zero Delta": "filter_zero_delta", + } + buttons = {} + for entity in entities: + if isinstance(entity, ButtonInfo) and entity.name in button_name_map: + buttons[button_name_map[entity.name]] = entity.key + + assert len(buttons) == 4, f"Expected 3 buttons, found {len(buttons)}" + + # Test 1: Min + sensor_values["filter_min"].clear() + client.button_command(buttons["filter_min"]) + try: + await asyncio.wait_for(filter_min_done, timeout=2.0) + except TimeoutError: + pytest.fail(f"Test 1 timed out. Values: {sensor_values['filter_min']}") + + expected = [1.0, 12.0, -2.0] + assert sensor_values["filter_min"] == pytest.approx(expected), ( + f"Test 1 failed: expected {expected}, got {sensor_values['filter_min']}" + ) + + # Test 2: Max + sensor_values["filter_max"].clear() + client.button_command(buttons["filter_max"]) + try: + await asyncio.wait_for(filter_max_done, timeout=2.0) + except TimeoutError: + pytest.fail(f"Test 2 timed out. Values: {sensor_values['filter_max']}") + + expected = [1.0, 5.0, 10.0] + assert sensor_values["filter_max"] == pytest.approx(expected), ( + f"Test 2 failed: expected {expected}, got {sensor_values['filter_max']}" + ) + + # Test 3: Baseline Max + sensor_values["filter_baseline_max"].clear() + client.button_command(buttons["filter_baseline_max"]) + try: + await asyncio.wait_for(filter_baseline_max_done, timeout=2.0) + except TimeoutError: + pytest.fail( + f"Test 3 timed out. Values: {sensor_values['filter_baseline_max']}" + ) + + expected = [1.0, 2.0, 3.0, 20.0] + assert sensor_values["filter_baseline_max"] == pytest.approx(expected), ( + f"Test 3 failed: expected {expected}, got {sensor_values['filter_baseline_max']}" + ) + + # Test 4: Zero Delta + sensor_values["filter_zero_delta"].clear() + client.button_command(buttons["filter_zero_delta"]) + try: + await asyncio.wait_for(filter_zero_delta_done, timeout=2.0) + except TimeoutError: + pytest.fail( + f"Test 4 timed out. Values: {sensor_values['filter_zero_delta']}" + ) + + expected = [1.0, 2.0] + assert sensor_values["filter_zero_delta"] == pytest.approx(expected), ( + f"Test 4 failed: expected {expected}, got {sensor_values['filter_zero_delta']}" + ) From cc393ce89343dfb43949bd7368d3320177fec15f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 20 Jan 2026 16:20:28 -1000 Subject: [PATCH 2/2] [web_server_idf] Replace heap-allocated url() with stack-based url_to() --- .../captive_portal/captive_portal.cpp | 10 +++++-- .../prometheus/prometheus_handler.h | 14 +++++----- .../web_server/ota/ota_web_server.cpp | 11 ++++++-- esphome/components/web_server/web_server.cpp | 26 +++++++++++++------ .../web_server_idf/web_server_idf.cpp | 23 +++++++--------- .../web_server_idf/web_server_idf.h | 12 +++++++-- 6 files changed, 62 insertions(+), 34 deletions(-) diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index bf65ae67c0..a577d42b50 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -96,10 +96,16 @@ void CaptivePortal::start() { } void CaptivePortal::handleRequest(AsyncWebServerRequest *req) { - if (req->url() == ESPHOME_F("/config.json")) { +#ifdef USE_ESP32 + char url_buf[AsyncWebServerRequest::URL_BUF_SIZE]; + StringRef url = req->url_to(url_buf); +#else + auto url = req->url(); +#endif + if (url == ESPHOME_F("/config.json")) { this->handle_config(req); return; - } else if (req->url() == ESPHOME_F("/wifisave")) { + } else if (url == ESPHOME_F("/wifisave")) { this->handle_wifisave(req); return; } diff --git a/esphome/components/prometheus/prometheus_handler.h b/esphome/components/prometheus/prometheus_handler.h index fc48ad67e3..7aecab99d1 100644 --- a/esphome/components/prometheus/prometheus_handler.h +++ b/esphome/components/prometheus/prometheus_handler.h @@ -41,12 +41,14 @@ class PrometheusHandler : public AsyncWebHandler, public Component { void add_label_name(EntityBase *obj, const std::string &value) { relabel_map_name_.insert({obj, value}); } bool canHandle(AsyncWebServerRequest *request) const override { - if (request->method() == HTTP_GET) { - if (request->url() == "/metrics") - return true; - } - - return false; + if (request->method() != HTTP_GET) + return false; +#ifdef USE_ESP32 + char url_buf[AsyncWebServerRequest::URL_BUF_SIZE]; + return request->url_to(url_buf) == "/metrics"; +#else + return request->url() == ESPHOME_F("/metrics"); +#endif } void handleRequest(AsyncWebServerRequest *req) override; diff --git a/esphome/components/web_server/ota/ota_web_server.cpp b/esphome/components/web_server/ota/ota_web_server.cpp index 3793f01eb5..4be162ccd3 100644 --- a/esphome/components/web_server/ota/ota_web_server.cpp +++ b/esphome/components/web_server/ota/ota_web_server.cpp @@ -32,8 +32,15 @@ class OTARequestHandler : public AsyncWebHandler { void handleUpload(AsyncWebServerRequest *request, const PlatformString &filename, size_t index, uint8_t *data, size_t len, bool final) override; bool canHandle(AsyncWebServerRequest *request) const override { - // Check if this is an OTA update request - bool is_ota_request = request->url() == "/update" && request->method() == HTTP_POST; + if (request->method() != HTTP_POST) + return false; + // Check if this is an OTA update request +#ifdef USE_ESP32 + char url_buf[AsyncWebServerRequest::URL_BUF_SIZE]; + bool is_ota_request = request->url_to(url_buf) == "/update"; +#else + bool is_ota_request = request->url() == ESPHOME_F("/update"); +#endif #if defined(USE_WEBSERVER_OTA_DISABLED) && defined(USE_CAPTIVE_PORTAL) // IMPORTANT: USE_WEBSERVER_OTA_DISABLED only disables OTA for the web_server component diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index e538a35e8c..3889711396 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -2175,7 +2175,12 @@ std::string WebServer::update_json_(update::UpdateEntity *obj, JsonDetail start_ #endif bool WebServer::canHandle(AsyncWebServerRequest *request) const { - const auto &url = request->url(); +#ifdef USE_ESP32 + char url_buf[AsyncWebServerRequest::URL_BUF_SIZE]; + StringRef url = request->url_to(url_buf); +#else + auto url = request->url(); +#endif const auto method = request->method(); // Static URL checks - use ESPHOME_F to keep strings in flash on ESP8266 @@ -2200,7 +2205,7 @@ bool WebServer::canHandle(AsyncWebServerRequest *request) const { #endif // Parse URL for component checks - UrlMatch match = match_url(url.c_str(), url.length(), true); + UrlMatch match = match_url(url.c_str(), url.size(), true); if (!match.valid) return false; @@ -2311,30 +2316,35 @@ bool WebServer::canHandle(AsyncWebServerRequest *request) const { return false; } void WebServer::handleRequest(AsyncWebServerRequest *request) { - const auto &url = request->url(); +#ifdef USE_ESP32 + char url_buf[AsyncWebServerRequest::URL_BUF_SIZE]; + StringRef url = request->url_to(url_buf); +#else + auto url = request->url(); +#endif // Handle static routes first - if (url == "/") { + if (url == ESPHOME_F("/")) { this->handle_index_request(request); return; } #if !defined(USE_ESP32) && defined(USE_ARDUINO) - if (url == "/events") { + if (url == ESPHOME_F("/events")) { this->events_.add_new_client(this, request); return; } #endif #ifdef USE_WEBSERVER_CSS_INCLUDE - if (url == "/0.css") { + if (url == ESPHOME_F("/0.css")) { this->handle_css_request(request); return; } #endif #ifdef USE_WEBSERVER_JS_INCLUDE - if (url == "/0.js") { + if (url == ESPHOME_F("/0.js")) { this->handle_js_request(request); return; } @@ -2349,7 +2359,7 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { // Parse URL for component routing // 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); + UrlMatch match = match_url(url.c_str(), url.size(), false, request->method() == HTTP_POST); // Route to appropriate handler based on domain // NOLINTNEXTLINE(readability-simplify-boolean-expr) diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 55d2040a3a..c96d36acf4 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -246,21 +246,16 @@ optional AsyncWebServerRequest::get_header(const char *name) const return request_get_header(*this, name); } -std::string AsyncWebServerRequest::url() const { - 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); - } +StringRef AsyncWebServerRequest::url_to(std::span buffer) const { + const char *uri = this->req_->uri; + const char *query_start = strchr(uri, '?'); + size_t uri_len = query_start ? static_cast(query_start - uri) : strlen(uri); + size_t copy_len = std::min(uri_len, URL_BUF_SIZE - 1); + memcpy(buffer.data(), uri, copy_len); + buffer[copy_len] = '\0'; // 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; + size_t decoded_len = url_decode(buffer.data()); + return StringRef(buffer.data(), decoded_len); } std::string AsyncWebServerRequest::host() const { return this->get_header("Host").value(); } diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 2a334a11e3..9dec0a8a81 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -3,12 +3,14 @@ #include "esphome/core/defines.h" #include "esphome/core/helpers.h" +#include "esphome/core/string_ref.h" #include #include #include #include #include +#include #include #include #include @@ -107,7 +109,10 @@ class AsyncWebServerRequest { ~AsyncWebServerRequest(); http_method method() const { return static_cast(this->req_->method); } - std::string url() const; + static constexpr size_t URL_BUF_SIZE = CONFIG_HTTPD_MAX_URI_LEN + 1; ///< Buffer size for url_to() + /// Write URL (without query string) to buffer, returns StringRef pointing to buffer. + /// URL is decoded (e.g., %20 -> space). + StringRef url_to(std::span buffer) const; std::string host() const; // NOLINTNEXTLINE(readability-identifier-naming) size_t contentLength() const { return this->req_->content_len; } @@ -303,7 +308,10 @@ class AsyncEventSource : public AsyncWebHandler { // NOLINTNEXTLINE(readability-identifier-naming) bool canHandle(AsyncWebServerRequest *request) const override { - return request->method() == HTTP_GET && request->url() == this->url_; + if (request->method() != HTTP_GET) + return false; + char url_buf[AsyncWebServerRequest::URL_BUF_SIZE]; + return request->url_to(url_buf) == this->url_; } // NOLINTNEXTLINE(readability-identifier-naming) void handleRequest(AsyncWebServerRequest *request) override;