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

Merge branch 'idf_no_heap_alloc_url' into integration

This commit is contained in:
J. Nick Koston
2026-01-20 16:21:00 -10:00
12 changed files with 482 additions and 80 deletions

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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<float>::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)

View File

@@ -291,22 +291,27 @@ optional<float> 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<float> 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 {};
}

View File

@@ -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<float> 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 {

View File

@@ -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

View File

@@ -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)

View File

@@ -246,21 +246,16 @@ optional<std::string> 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<char, URL_BUF_SIZE> buffer) const {
const char *uri = this->req_->uri;
const char *query_start = strchr(uri, '?');
size_t uri_len = query_start ? static_cast<size_t>(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(); }

View File

@@ -3,12 +3,14 @@
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#include "esphome/core/string_ref.h"
#include <esp_http_server.h>
#include <atomic>
#include <functional>
#include <list>
#include <map>
#include <span>
#include <string>
#include <utility>
#include <vector>
@@ -110,7 +112,10 @@ class AsyncWebServerRequest {
~AsyncWebServerRequest();
http_method method() const { return static_cast<http_method>(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<char, URL_BUF_SIZE> buffer) const;
std::string host() const;
// NOLINTNEXTLINE(readability-identifier-naming)
size_t contentLength() const { return this->req_->content_len; }
@@ -306,7 +311,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;

View File

@@ -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

View File

@@ -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

View File

@@ -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']}"
)