Compare commits

...
11 changed files with 509 additions and 20 deletions

No files matched your search

+1
View File
@@ -59,6 +59,7 @@ from esphome.cpp_helpers import ( # noqa: F401
add_variable,
build_registry_entry,
build_registry_list,
build_registry_list_merged,
extract_registry_entry_config,
get_slot_count,
gpio_pin_expression,
+9 -19
View File
@@ -45,7 +45,6 @@ from esphome.const import (
CONF_TIMEOUT,
CONF_TO,
CONF_TRIGGER_ID,
CONF_TYPE_ID,
CONF_UNIT_OF_MEASUREMENT,
CONF_USE_FAHRENHEIT,
CONF_VALUE,
@@ -1009,26 +1008,17 @@ def _timeout_throttle_period(
return timeout[CONF_TIMEOUT]
def _timeout_throttle_merge(first: ConfigType, second: ConfigType) -> tuple | None:
if (period := _timeout_throttle_period(first, second)) is None:
return None
return TimeoutThrottleFilter, [period]
async def _build_chain_filters(config: list[ConfigType]) -> list:
"""Like build_filters, but merges the radar `timeout` + `throttle_with_priority` pair into one filter."""
filters = []
i = 0
while i < len(config):
if (
i + 1 < len(config)
and (period := _timeout_throttle_period(config[i], config[i + 1]))
is not None
):
filter_id = config[i][CONF_TYPE_ID].copy()
filter_id.type = TimeoutThrottleFilter
var = cg.new_Pvariable(filter_id, period)
await cg.register_component(var, {})
filters.append(var)
i += 2
continue
filters.append(await cg.build_registry_entry(FILTER_REGISTRY, config[i]))
i += 1
return filters
return await cg.build_registry_list_merged(
FILTER_REGISTRY, config, _timeout_throttle_merge
)
_CALLBACK_AUTOMATIONS = (
+115 -1
View File
@@ -13,7 +13,9 @@ from esphome.const import (
CONF_ON_RAW_VALUE,
CONF_ON_VALUE,
CONF_STATE,
CONF_TIMEOUT,
CONF_TO,
CONF_VALUE,
DEVICE_CLASS_DATE,
DEVICE_CLASS_EMPTY,
DEVICE_CLASS_TIMESTAMP,
@@ -59,6 +61,18 @@ AppendFilter = text_sensor_ns.class_("AppendFilter", Filter)
PrependFilter = text_sensor_ns.class_("PrependFilter", Filter)
SubstituteFilter = text_sensor_ns.class_("SubstituteFilter", Filter)
MapFilter = text_sensor_ns.class_("MapFilter", Filter)
ThrottleFilter = text_sensor_ns.class_("ThrottleFilter", Filter)
ThrottleWithPriorityFilter = text_sensor_ns.class_(
"ThrottleWithPriorityFilter", ThrottleFilter
)
TimeoutFilterBase = text_sensor_ns.class_("TimeoutFilterBase", Filter, cg.Component)
TimeoutFilterLast = text_sensor_ns.class_("TimeoutFilterLast", TimeoutFilterBase)
TimeoutFilterConfigured = text_sensor_ns.class_(
"TimeoutFilterConfigured", TimeoutFilterBase
)
TimeoutThrottleFilter = text_sensor_ns.class_(
"TimeoutThrottleFilter", TimeoutFilterLast
)
Substitution = text_sensor_ns.struct("Substitution")
@@ -128,6 +142,80 @@ async def substitution_filter_to_code(
return cg.new_Pvariable(filter_id, _substitution_table(config))
@FILTER_REGISTRY.register(
"throttle", ThrottleFilter, cv.positive_time_period_milliseconds
)
async def throttle_filter_to_code(config: ConfigType, filter_id: ID) -> MockObj:
return cg.new_Pvariable(filter_id, config)
THROTTLE_WITH_PRIORITY_SCHEMA = cv.maybe_simple_value(
{
cv.Required(CONF_TIMEOUT): cv.positive_time_period_milliseconds,
cv.Optional(CONF_VALUE, default=[]): cv.ensure_list(cv.string),
},
key=CONF_TIMEOUT,
)
@FILTER_REGISTRY.register(
"throttle_with_priority", ThrottleWithPriorityFilter, THROTTLE_WITH_PRIORITY_SCHEMA
)
async def throttle_with_priority_filter_to_code(
config: ConfigType, filter_id: ID
) -> MockObj:
if not (values := config[CONF_VALUE]):
filter_id = filter_id.copy()
filter_id.type = ThrottleFilter
return cg.new_Pvariable(filter_id, config[CONF_TIMEOUT])
return cg.new_Pvariable(filter_id, config[CONF_TIMEOUT], _priority_table(values))
def _priority_table(values: list[str]) -> MockObj:
"""Shared PROGMEM list of priority texts, ended by nullptr; nullptr without any."""
if not values:
return cg.nullptr
return cg.shared_progmem_array(
"text_sensor_priorities",
cg.const_char_ptr,
cg.ArrayInitializer(*values, cg.nullptr),
)
def timeout_throttle_filters(priority: str, period_ms: int = 1000) -> list[ConfigType]:
"""Default filters for a text entity: resend the last value after a pause, at most one value a period,
`priority` at once. Built as one filter, like sensor.TIMEOUT_THROTTLE_FILTERS."""
period = cv.TimePeriod(milliseconds=period_ms)
return [
{"timeout": {CONF_TIMEOUT: period, CONF_VALUE: "last"}},
{"throttle_with_priority": {CONF_TIMEOUT: period, CONF_VALUE: [priority]}},
]
TIMEOUT_SCHEMA = cv.maybe_simple_value(
{
cv.Required(CONF_TIMEOUT): cv.positive_time_period_milliseconds,
cv.Optional(CONF_VALUE, default="last"): cv.string,
},
key=CONF_TIMEOUT,
)
@FILTER_REGISTRY.register("timeout", TimeoutFilterBase, TIMEOUT_SCHEMA)
async def timeout_filter_to_code(config: ConfigType, filter_id: ID) -> MockObj:
filter_id = filter_id.copy()
args = [config[CONF_TIMEOUT]]
# "last" is a keyword, so a timeout cannot send that literal text
if (value := config[CONF_VALUE]) == "last":
filter_id.type = TimeoutFilterLast
else:
filter_id.type = TimeoutFilterConfigured
args.append(value)
var = cg.new_Pvariable(filter_id, *args)
await cg.register_component(var, {})
return var
validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True, space="_")
_TEXT_SENSOR_SCHEMA = (
@@ -184,6 +272,32 @@ async def build_filters(config):
return await cg.build_registry_list(FILTER_REGISTRY, config)
def _timeout_throttle_pair(first: ConfigType, second: ConfigType) -> tuple | None:
"""Period and priority texts of a `timeout` (value `last`) directly followed by a
`throttle_with_priority` with the same period, or None."""
if (timeout := first.get(CONF_TIMEOUT)) is None or (
throttle := second.get("throttle_with_priority")
) is None:
return None
if timeout[CONF_VALUE] != "last" or timeout[CONF_TIMEOUT] != throttle[CONF_TIMEOUT]:
return None
return timeout[CONF_TIMEOUT], throttle[CONF_VALUE]
def _timeout_throttle_merge(first: ConfigType, second: ConfigType) -> tuple | None:
if (pair := _timeout_throttle_pair(first, second)) is None:
return None
period, values = pair
return TimeoutThrottleFilter, [period, _priority_table(values)]
async def _build_chain_filters(config: list[ConfigType]) -> list:
"""Like build_filters, but merges a `timeout` + `throttle_with_priority` pair into one filter."""
return await cg.build_registry_list_merged(
FILTER_REGISTRY, config, _timeout_throttle_merge
)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_VALUE, "add_on_state_callback", [(cg.std_string, "x")]
@@ -205,7 +319,7 @@ async def setup_text_sensor_core_(var, config):
if config.get(CONF_FILTERS): # must exist and not be empty
cg.add_define("USE_TEXT_SENSOR_FILTER")
filters = await build_filters(config[CONF_FILTERS])
filters = await _build_chain_filters(config[CONF_FILTERS])
for filter_ in filters:
cg.add(var.add_filter(filter_))
+60
View File
@@ -3,6 +3,7 @@
#include "filter.h"
#include "text_sensor.h"
#include "esphome/core/application.h"
#include "esphome/core/log.h"
#include "esphome/core/hal.h"
@@ -98,6 +99,65 @@ bool MapFilter::new_value(std::string &value) {
return true;
}
// Throttle
// True when a value may pass: the period is over, or the value is in the nullptr-ended `prioritized` list
static bool throttle_passes(uint32_t &last_input, uint32_t period, const std::string &value,
const char *const *prioritized) {
const uint32_t now = App.get_loop_component_start_time();
bool pass = last_input == 0 || now - last_input >= period;
for (const char *const *p = prioritized; !pass && p != nullptr && *p != nullptr; p++)
pass = value == *p;
if (pass)
last_input = now;
return pass;
}
bool ThrottleFilter::new_value(std::string &value) {
return throttle_passes(this->last_input_, this->min_time_between_inputs_, value, nullptr);
}
bool ThrottleWithPriorityFilter::new_value(std::string &value) {
return throttle_passes(this->last_input_, this->min_time_between_inputs_, value, this->prioritized_);
}
// Timeout
bool TimeoutFilterBase::new_value(std::string &value) {
this->timeout_start_time_ = App.get_loop_component_start_time();
this->enable_loop();
return true;
}
void TimeoutFilterBase::loop() {
if (App.get_loop_component_start_time() - this->timeout_start_time_ < this->time_period_)
return;
// Before sending, so a callback that publishes to this sensor again re-arms the timeout
this->disable_loop();
this->send_timeout_value();
}
void TimeoutFilterBase::send_(const char *text, size_t len) {
if (this->next_ == nullptr) {
this->parent_->internal_send_state_to_frontend(text, len);
} else {
this->next_->input(std::string(text, len));
}
}
bool TimeoutFilterLast::new_value(std::string &value) {
this->last_ = value; // reuses last_'s buffer
return TimeoutFilterBase::new_value(value);
}
bool TimeoutThrottleFilter::new_value(std::string &value) {
TimeoutFilterLast::new_value(value);
return throttle_passes(this->last_input_, this->time_period_, value, this->prioritized_);
}
void TimeoutThrottleFilter::send_timeout_value() {
if (throttle_passes(this->last_input_, this->time_period_, this->last_, this->prioritized_))
this->send_(this->last_.data(), this->last_.size());
}
} // namespace esphome::text_sensor
#endif // USE_TEXT_SENSOR_FILTER
+76
View File
@@ -169,6 +169,82 @@ class MapFilter : public Filter {
const Substitution *mappings_;
};
/// Drops a value that comes sooner than `min_time_between_inputs` after the last one let through.
class ThrottleFilter : public Filter {
public:
explicit ThrottleFilter(uint32_t min_time_between_inputs) : min_time_between_inputs_(min_time_between_inputs) {}
bool new_value(std::string &value) override;
protected:
uint32_t last_input_{0};
uint32_t min_time_between_inputs_;
};
/// Same as ThrottleFilter, but a value in `prioritized` always passes. `prioritized` is a codegen table
/// in flash, shared by filters with the same list and ended by nullptr.
class ThrottleWithPriorityFilter : public ThrottleFilter {
public:
ThrottleWithPriorityFilter(uint32_t min_time_between_inputs, const char *const *prioritized)
: ThrottleFilter(min_time_between_inputs), prioritized_(prioritized) {}
bool new_value(std::string &value) override;
protected:
const char *const *prioritized_;
};
/// Passes every value on and, once none has come for `time_period`, sends one value again. A Component rather than
/// a scheduler timeout for the reason given on the sensor's TimeoutFilterBase: it re-arms on every value.
class TimeoutFilterBase : public Filter, public Component {
public:
bool new_value(std::string &value) override;
void loop() override;
float get_setup_priority() const override { return setup_priority::HARDWARE; }
protected:
explicit TimeoutFilterBase(uint32_t time_period) : time_period_(time_period) { this->disable_loop(); }
/// Sends `text`; never moves a stored string on, so it keeps its buffer.
void send_(const char *text, size_t len);
virtual void send_timeout_value() = 0;
uint32_t time_period_;
uint32_t timeout_start_time_{0};
};
/// Sends the last value again.
class TimeoutFilterLast : public TimeoutFilterBase {
public:
explicit TimeoutFilterLast(uint32_t time_period) : TimeoutFilterBase(time_period) {}
bool new_value(std::string &value) override;
protected:
void send_timeout_value() override { this->send_(this->last_.data(), this->last_.size()); }
std::string last_;
};
/// Codegen use only: `timeout` (value `last`) followed by `throttle_with_priority` with the same period, as one
/// object. Output matches the two filter chain exactly.
class TimeoutThrottleFilter : public TimeoutFilterLast {
public:
TimeoutThrottleFilter(uint32_t time_period, const char *const *prioritized)
: TimeoutFilterLast(time_period), prioritized_(prioritized) {}
bool new_value(std::string &value) override;
protected:
void send_timeout_value() override;
const char *const *prioritized_;
uint32_t last_input_{0}; // When the throttle stage last let a value through
};
/// Sends the configured text.
class TimeoutFilterConfigured : public TimeoutFilterBase {
public:
TimeoutFilterConfigured(uint32_t time_period, const char *value) : TimeoutFilterBase(time_period), value_(value) {}
protected:
void send_timeout_value() override { this->send_(this->value_, strlen(this->value_)); }
const char *value_;
};
} // namespace esphome::text_sensor
#endif // USE_TEXT_SENSOR_FILTER
+25
View File
@@ -372,6 +372,31 @@ async def build_registry_list(registry, config):
return actions
async def build_registry_list_merged(
registry: Registry,
config: list[ConfigType],
merge: Callable[[ConfigType, ConfigType], tuple[Any, list] | None],
) -> list[MockObj]:
"""Like build_registry_list, but two neighbouring entries that ``merge`` accepts are built as one Component.
``merge(first, second)`` returns the merged class and its constructor arguments, or None.
"""
built = []
i = 0
while i < len(config):
if i + 1 < len(config) and (merged := merge(config[i], config[i + 1])):
type_id = config[i][CONF_TYPE_ID].copy()
type_id.type, args = merged
var = new_Pvariable(type_id, *args)
await register_component(var, {})
built.append(var)
i += 2
continue
built.append(await build_registry_entry(registry, config[i]))
i += 1
return built
async def past_safe_mode():
if CONF_SAFE_MODE not in CORE.config:
return None
@@ -100,3 +100,43 @@ def test_substitution_table_names_avoid_user_ids(
assert len(tables) == 2
assert "text_sensor_substitutions" not in tables
assert "text_sensor_mapfilter_id_table" not in tables
def test_throttle_and_timeout_filters(generate_main: Callable[[str], str]) -> None:
"""Priority values go in a PROGMEM table ended by nullptr; no priority is a plain throttle."""
main_cpp = generate_main(
"tests/component_tests/text_sensor/test_text_sensor_throttle_timeout.yaml"
)
table = re.search(
r'static constexpr const char \* (\w+)\[\] PROGMEM = \{"NA", nullptr\}',
main_cpp,
)
assert table is not None
# timeout (last) then throttle_with_priority with the same period merge into one filter
assert f"text_sensor::TimeoutThrottleFilter(1000, {table.group(1)})" in main_cpp
alone = re.search(
r'static constexpr const char \* (\w+)\[\] PROGMEM = \{"Off", "NA", nullptr\}',
main_cpp,
)
assert alone is not None
assert f"text_sensor::ThrottleWithPriorityFilter(300, {alone.group(1)})" in main_cpp
assert "text_sensor::ThrottleFilter(500)" in main_cpp
assert "text_sensor::TimeoutThrottleFilter(2000, nullptr)" in main_cpp
assert 'text_sensor::TimeoutFilterConfigured(30000, "Offline")' in main_cpp
def test_timeout_throttle_filters_default_merges() -> None:
"""The validated default is a pair the chain builder merges into one filter."""
from esphome.components.text_sensor import (
_timeout_throttle_pair,
timeout_throttle_filters,
validate_filters,
)
first, second = validate_filters(timeout_throttle_filters("NA"))
pair = _timeout_throttle_pair(first, second)
assert pair is not None
period, values = pair
assert period.total_milliseconds == 1000
assert values == ["NA"]
@@ -0,0 +1,35 @@
---
esphome:
name: test
esp8266:
board: d1_mini_lite
text_sensor:
- platform: template
id: ts_position
filters:
- timeout: 1s
- throttle_with_priority:
timeout: 1s
value: NA
- platform: template
id: ts_plain
filters:
- throttle_with_priority: 500ms
- timeout:
timeout: 30s
value: Offline
- platform: template
id: ts_merged_plain
filters:
- timeout: 2s
- throttle_with_priority: 2s
- platform: template
id: ts_priority_alone
filters:
- throttle_with_priority:
timeout: 300ms
value:
- "Off"
- NA
+11
View File
@@ -0,0 +1,11 @@
import esphome.codegen as cg
from esphome.types import ConfigType
from tests.testing_helpers import ComponentManifestOverride
def override_manifest(manifest: ComponentManifestOverride) -> None:
async def to_code_testing(config: ConfigType) -> None:
# filter.cpp only compiles with the define the real to_code emits for configured filters
cg.add_define("USE_TEXT_SENSOR_FILTER")
manifest.to_code = to_code_testing
+23
View File
@@ -30,6 +30,29 @@ text_sensor:
- substitute:
- BAR -> baz
- platform: template
name: "Test Throttle"
id: test_throttle
filters:
- throttle: 1s
- platform: template
name: "Test Throttle With Priority"
id: test_throttle_with_priority
filters:
- timeout: 1s
- throttle_with_priority:
timeout: 1s
value: NA
- platform: template
name: "Test Timeout Value"
id: test_timeout_value
filters:
- timeout:
timeout: 30s
value: Offline
- platform: template
name: "Test Map Single"
id: test_map_single
@@ -0,0 +1,114 @@
#include <gtest/gtest.h>
#include <string>
#include <vector>
#include "esphome/components/text_sensor/filter.h"
#include "esphome/components/text_sensor/text_sensor.h"
#include "esphome/core/application.h"
namespace esphome::text_sensor::testing {
class TextFilterTest : public ::testing::Test {
protected:
void SetUp() override {
this->sensor_.add_on_state_callback([this](const std::string &state) { this->seen_.push_back(state); });
}
void publish_at_(uint32_t now, const char *value) {
LoopBlockingGuard guard{nullptr, nullptr, now};
this->sensor_.publish_state(value);
}
static void loop_at(Component &filter, uint32_t now) {
LoopBlockingGuard guard{nullptr, nullptr, now};
filter.loop();
}
TextSensor sensor_;
std::vector<std::string> seen_;
};
static constexpr const char *PRIORITIZED[] = {"NA", nullptr};
TEST_F(TextFilterTest, ThrottleDropsValuesInsideThePeriod) {
ThrottleFilter throttle(1000);
this->sensor_.add_filter(&throttle);
this->publish_at_(1000, "a");
this->publish_at_(1500, "b");
this->publish_at_(2000, "c");
EXPECT_EQ(this->seen_, (std::vector<std::string>{"a", "c"}));
}
TEST_F(TextFilterTest, PrioritizedValuePassesAtOnce) {
ThrottleWithPriorityFilter throttle(1000, PRIORITIZED);
this->sensor_.add_filter(&throttle);
this->publish_at_(1000, "a");
this->publish_at_(1100, "NA");
this->publish_at_(1200, "b");
this->publish_at_(2100, "c");
EXPECT_EQ(this->seen_, (std::vector<std::string>{"a", "NA", "c"}));
}
TEST_F(TextFilterTest, TimeoutRearmsOnEachValueAndFiresOnce) {
TimeoutFilterLast timeout(1000);
this->sensor_.add_filter(&timeout);
this->publish_at_(1000, "a");
this->publish_at_(1800, "b");
loop_at(timeout, 2500);
EXPECT_EQ(this->seen_, (std::vector<std::string>{"a", "b"}));
EXPECT_FALSE(timeout.is_idle());
loop_at(timeout, 2800);
EXPECT_EQ(this->seen_, (std::vector<std::string>{"a", "b", "b"}));
EXPECT_TRUE(timeout.is_idle());
}
TEST_F(TextFilterTest, TimeoutSendsTheConfiguredValue) {
TimeoutFilterConfigured timeout(1000, "Offline");
this->sensor_.add_filter(&timeout);
this->publish_at_(1000, "a");
loop_at(timeout, 2000);
EXPECT_EQ(this->seen_, (std::vector<std::string>{"a", "Offline"}));
}
// Feeds the same values to the timeout + throttle chain and to the merged filter
static std::vector<std::string> run_sequence(std::initializer_list<Filter *> filters, Component &timeout) {
TextSensor sensor;
std::vector<std::string> seen;
sensor.add_on_state_callback([&seen](const std::string &state) { seen.push_back(state); });
sensor.add_filters(filters);
const auto publish_at = [&sensor](uint32_t now, const char *value) {
LoopBlockingGuard guard{nullptr, nullptr, now};
sensor.publish_state(value);
};
const auto loop_at = [&timeout](uint32_t now) {
LoopBlockingGuard guard{nullptr, nullptr, now};
timeout.loop();
};
publish_at(1000, "a");
publish_at(1200, "b"); // held by the throttle, sent once updates pause
loop_at(2200);
publish_at(2300, "c");
publish_at(2400, "NA"); // a priority value passes at once
publish_at(2500, "d");
loop_at(3500);
return seen;
}
TEST(TextFilterChain, CombinedTimeoutThrottleMatchesTheChain) {
TimeoutFilterLast timeout(1000);
ThrottleWithPriorityFilter throttle(1000, PRIORITIZED);
const auto chain = run_sequence({&timeout, &throttle}, timeout);
TimeoutThrottleFilter combined(1000, PRIORITIZED);
EXPECT_EQ(run_sequence({&combined}, combined), chain);
EXPECT_EQ(chain, (std::vector<std::string>{"a", "b", "NA", "d"}));
}
TEST_F(TextFilterTest, CombinedWithoutPriorityIsAPlainThrottle) {
TimeoutThrottleFilter combined(1000, nullptr);
this->sensor_.add_filter(&combined);
this->publish_at_(1000, "a");
this->publish_at_(1300, "NA");
loop_at(combined, 2300);
EXPECT_EQ(this->seen_, (std::vector<std::string>{"a", "NA"}));
}
} // namespace esphome::text_sensor::testing