mirror of
https://github.com/esphome/esphome.git
synced 2026-10-14 02:02:51 +00:00
[text_sensor] Share substitute and map filter tables in flash (#20076)
This commit is contained in:
@@ -59,6 +59,7 @@ 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)
|
||||
Substitution = text_sensor_ns.struct("Substitution")
|
||||
|
||||
|
||||
@FILTER_REGISTRY.register("lambda", LambdaFilter, cv.returning_lambda)
|
||||
@@ -102,34 +103,29 @@ def validate_mapping(value):
|
||||
)(value)
|
||||
|
||||
|
||||
@FILTER_REGISTRY.register(
|
||||
"substitute", SubstituteFilter, cv.ensure_list(validate_mapping)
|
||||
)
|
||||
async def substitute_filter_to_code(config, filter_id):
|
||||
substitutions = [
|
||||
def _substitution_table(config: list[ConfigType]) -> MockObj:
|
||||
"""Shared PROGMEM Substitution table, ended by an empty pair so filters store no count."""
|
||||
pairs = [
|
||||
cg.StructInitializer(
|
||||
cg.MockObj("Substitution", "esphome::text_sensor::"),
|
||||
("from", conf[CONF_FROM]),
|
||||
("to", conf[CONF_TO]),
|
||||
Substitution, ("from", conf[CONF_FROM]), ("to", conf[CONF_TO])
|
||||
)
|
||||
for conf in config
|
||||
]
|
||||
return cg.new_Pvariable(
|
||||
filter_id, cg.TemplateArguments(len(substitutions)), substitutions
|
||||
end = cg.RawExpression("nullptr")
|
||||
pairs.append(cg.StructInitializer(Substitution, ("from", end), ("to", end)))
|
||||
return cg.shared_progmem_array(
|
||||
"text_sensor_substitutions", Substitution, cg.ArrayInitializer(*pairs)
|
||||
)
|
||||
|
||||
|
||||
@FILTER_REGISTRY.register(
|
||||
"substitute", SubstituteFilter, cv.ensure_list(validate_mapping)
|
||||
)
|
||||
@FILTER_REGISTRY.register("map", MapFilter, cv.ensure_list(validate_mapping))
|
||||
async def map_filter_to_code(config, filter_id):
|
||||
mappings = [
|
||||
cg.StructInitializer(
|
||||
cg.MockObj("Substitution", "esphome::text_sensor::"),
|
||||
("from", conf[CONF_FROM]),
|
||||
("to", conf[CONF_TO]),
|
||||
)
|
||||
for conf in config
|
||||
]
|
||||
return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(mappings)), mappings)
|
||||
async def substitution_filter_to_code(
|
||||
config: list[ConfigType], filter_id: ID
|
||||
) -> MockObj:
|
||||
return cg.new_Pvariable(filter_id, _substitution_table(config))
|
||||
|
||||
|
||||
validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True, space="_")
|
||||
|
||||
@@ -73,25 +73,25 @@ bool PrependFilter::new_value(std::string &value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Substitute — non-template helper
|
||||
bool substitute_filter_apply(const Substitution *substitutions, size_t count, std::string &value) {
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
const size_t from_len = strlen(substitutions[i].from);
|
||||
const size_t to_len = strlen(substitutions[i].to);
|
||||
// Substitute
|
||||
bool SubstituteFilter::new_value(std::string &value) {
|
||||
for (const Substitution *s = this->substitutions_; s->from != nullptr; s++) {
|
||||
const size_t from_len = strlen(s->from);
|
||||
const size_t to_len = strlen(s->to);
|
||||
std::size_t pos = 0;
|
||||
while ((pos = value.find(substitutions[i].from, pos, from_len)) != std::string::npos) {
|
||||
value.replace(pos, from_len, substitutions[i].to, to_len);
|
||||
while ((pos = value.find(s->from, pos, from_len)) != std::string::npos) {
|
||||
value.replace(pos, from_len, s->to, to_len);
|
||||
pos += to_len;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Map — non-template helper
|
||||
bool map_filter_apply(const Substitution *mappings, size_t count, std::string &value) {
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
if (value == mappings[i].from) {
|
||||
value.assign(mappings[i].to);
|
||||
// Map
|
||||
bool MapFilter::new_value(std::string &value) {
|
||||
for (const Substitution *m = this->mappings_; m->from != nullptr; m++) {
|
||||
if (value == m->from) {
|
||||
value.assign(m->to);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_TEXT_SENSOR_FILTER
|
||||
|
||||
#include <array>
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
@@ -123,25 +121,18 @@ struct Substitution {
|
||||
const char *to;
|
||||
};
|
||||
|
||||
/// Non-template helper (implementation in filter.cpp)
|
||||
bool substitute_filter_apply(const Substitution *substitutions, size_t count, std::string &value);
|
||||
|
||||
/// A simple filter that replaces a substring with another substring.
|
||||
/// N is set by code generation to match the exact number of substitutions configured in YAML.
|
||||
template<size_t N> class SubstituteFilter : public Filter {
|
||||
/// `substitutions` is a codegen table in flash, shared by filters with the same list and
|
||||
/// ended by an entry whose `from` is nullptr, so no count is stored.
|
||||
class SubstituteFilter : public Filter {
|
||||
public:
|
||||
explicit SubstituteFilter(const std::initializer_list<Substitution> &substitutions) {
|
||||
init_array_from(this->substitutions_, substitutions);
|
||||
}
|
||||
bool new_value(std::string &value) override { return substitute_filter_apply(this->substitutions_.data(), N, value); }
|
||||
explicit SubstituteFilter(const Substitution *substitutions) : substitutions_(substitutions) {}
|
||||
bool new_value(std::string &value) override;
|
||||
|
||||
protected:
|
||||
std::array<Substitution, N> substitutions_{};
|
||||
const Substitution *substitutions_;
|
||||
};
|
||||
|
||||
/// Non-template helper (implementation in filter.cpp)
|
||||
bool map_filter_apply(const Substitution *mappings, size_t count, std::string &value);
|
||||
|
||||
/** A filter that maps values from one set to another
|
||||
*
|
||||
* Uses linear search instead of std::map for typical small datasets (2-20 mappings).
|
||||
@@ -166,17 +157,16 @@ bool map_filter_apply(const Substitution *mappings, size_t count, std::string &v
|
||||
*
|
||||
* Break-even point: ~35-40 mappings, but ESPHome configs rarely exceed 20
|
||||
*
|
||||
* N is set by code generation to match the exact number of mappings configured in YAML.
|
||||
* `mappings` is a codegen table in flash, shared by filters with the same list and ended by
|
||||
* an entry whose `from` is nullptr, so no count is stored.
|
||||
*/
|
||||
template<size_t N> class MapFilter : public Filter {
|
||||
class MapFilter : public Filter {
|
||||
public:
|
||||
explicit MapFilter(const std::initializer_list<Substitution> &mappings) {
|
||||
init_array_from(this->mappings_, mappings);
|
||||
}
|
||||
bool new_value(std::string &value) override { return map_filter_apply(this->mappings_.data(), N, value); }
|
||||
explicit MapFilter(const Substitution *mappings) : mappings_(mappings) {}
|
||||
bool new_value(std::string &value) override;
|
||||
|
||||
protected:
|
||||
std::array<Substitution, N> mappings_{};
|
||||
const Substitution *mappings_;
|
||||
};
|
||||
|
||||
} // namespace esphome::text_sensor
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"""Tests for the text sensor component."""
|
||||
|
||||
from collections.abc import Callable
|
||||
import re
|
||||
|
||||
from tests.component_tests.helpers import INTERNAL_BIT, extract_packed_value
|
||||
|
||||
|
||||
@@ -62,3 +65,38 @@ def test_text_sensor_device_class_set(generate_main):
|
||||
assert packed_ts_2 != 0
|
||||
packed_ts_3 = extract_packed_value(main_cpp, "ts_3")
|
||||
assert packed_ts_3 != 0
|
||||
|
||||
|
||||
def test_substitution_filters_share_progmem_tables(
|
||||
generate_main: Callable[[str], str],
|
||||
) -> None:
|
||||
"""Map and substitute pairs live in PROGMEM tables; equal lists share one."""
|
||||
main_cpp = generate_main(
|
||||
"tests/component_tests/text_sensor/test_text_sensor_filter_tables.yaml"
|
||||
)
|
||||
|
||||
tables = re.findall(
|
||||
r"static constexpr text_sensor::Substitution (\w+)\[\] PROGMEM", main_cpp
|
||||
)
|
||||
assert len(tables) == 2
|
||||
map_table, sub_table = tables
|
||||
assert main_cpp.count(f"text_sensor::MapFilter({map_table})") == 2
|
||||
assert f"text_sensor::SubstituteFilter({sub_table})" in main_cpp
|
||||
# Each table ends with an empty pair so the filters store no count.
|
||||
assert main_cpp.count(".from = nullptr") == 2
|
||||
|
||||
|
||||
def test_substitution_table_names_avoid_user_ids(
|
||||
generate_main: Callable[[str], str],
|
||||
) -> None:
|
||||
"""A user id equal to a table name cannot collide with the generated table."""
|
||||
main_cpp = generate_main(
|
||||
"tests/component_tests/text_sensor/test_text_sensor_filter_table_id_clash.yaml"
|
||||
)
|
||||
|
||||
tables = re.findall(
|
||||
r"static constexpr text_sensor::Substitution (\w+)\[\] PROGMEM", main_cpp
|
||||
)
|
||||
assert len(tables) == 2
|
||||
assert "text_sensor_substitutions" not in tables
|
||||
assert "text_sensor_mapfilter_id_table" not in tables
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp8266:
|
||||
board: d1_mini_lite
|
||||
|
||||
text_sensor:
|
||||
- platform: template
|
||||
id: text_sensor_substitutions
|
||||
filters:
|
||||
- map:
|
||||
- "ON -> Open"
|
||||
- platform: template
|
||||
id: text_sensor_mapfilter_id_table
|
||||
filters:
|
||||
- map:
|
||||
- "OFF -> Closed"
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp8266:
|
||||
board: d1_mini_lite
|
||||
|
||||
text_sensor:
|
||||
- platform: template
|
||||
id: ts_map_a
|
||||
filters:
|
||||
- map:
|
||||
- "ON -> Open"
|
||||
- "OFF -> Closed"
|
||||
- platform: template
|
||||
id: ts_map_b
|
||||
filters:
|
||||
- map:
|
||||
- "ON -> Open"
|
||||
- "OFF -> Closed"
|
||||
- platform: template
|
||||
id: ts_sub
|
||||
filters:
|
||||
- substitute:
|
||||
- "foo -> bar"
|
||||
Reference in New Issue
Block a user