[text_sensor] Share substitute and map filter tables in flash (#20076)

This commit is contained in:
J. Nick Koston
2026-10-12 13:40:38 -10:00
committed by GitHub
parent b66cde0270
commit 3ad83ad02b
6 changed files with 121 additions and 54 deletions
+16 -20
View File
@@ -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="_")
+12 -12
View File
@@ -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;
}
}
+12 -22
View File
@@ -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"