diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index 959ce77c7a..2ff0421f39 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -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="_") diff --git a/esphome/components/text_sensor/filter.cpp b/esphome/components/text_sensor/filter.cpp index 9d18fb9b1a..54457d8238 100644 --- a/esphome/components/text_sensor/filter.cpp +++ b/esphome/components/text_sensor/filter.cpp @@ -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; } } diff --git a/esphome/components/text_sensor/filter.h b/esphome/components/text_sensor/filter.h index 6db76dcb64..7898d46686 100644 --- a/esphome/components/text_sensor/filter.h +++ b/esphome/components/text_sensor/filter.h @@ -3,8 +3,6 @@ #include "esphome/core/defines.h" #ifdef USE_TEXT_SENSOR_FILTER -#include - #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 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 &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 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 class MapFilter : public Filter { +class MapFilter : public Filter { public: - explicit MapFilter(const std::initializer_list &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 mappings_{}; + const Substitution *mappings_; }; } // namespace esphome::text_sensor diff --git a/tests/component_tests/text_sensor/test_text_sensor.py b/tests/component_tests/text_sensor/test_text_sensor.py index eb25af3095..9eda24f7b9 100644 --- a/tests/component_tests/text_sensor/test_text_sensor.py +++ b/tests/component_tests/text_sensor/test_text_sensor.py @@ -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 diff --git a/tests/component_tests/text_sensor/test_text_sensor_filter_table_id_clash.yaml b/tests/component_tests/text_sensor/test_text_sensor_filter_table_id_clash.yaml new file mode 100644 index 0000000000..d0526cab19 --- /dev/null +++ b/tests/component_tests/text_sensor/test_text_sensor_filter_table_id_clash.yaml @@ -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" diff --git a/tests/component_tests/text_sensor/test_text_sensor_filter_tables.yaml b/tests/component_tests/text_sensor/test_text_sensor_filter_tables.yaml new file mode 100644 index 0000000000..ac422c3309 --- /dev/null +++ b/tests/component_tests/text_sensor/test_text_sensor_filter_tables.yaml @@ -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"