From c34a57df7b65c151f761e7afed5d042cb0f75d3f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Oct 2025 12:48:27 -1000 Subject: [PATCH] text_sensor filters --- esphome/components/text_sensor/__init__.py | 25 +++++-- esphome/components/text_sensor/filter.cpp | 27 ++++++-- esphome/components/text_sensor/filter.h | 44 +++++++++---- tests/components/text_sensor/common.yaml | 66 +++++++++++++++++++ .../text_sensor/test.esp8266-ard.yaml | 1 + 5 files changed, 140 insertions(+), 23 deletions(-) create mode 100644 tests/components/text_sensor/common.yaml create mode 100644 tests/components/text_sensor/test.esp8266-ard.yaml diff --git a/esphome/components/text_sensor/__init__.py b/esphome/components/text_sensor/__init__.py index f7b3b5c55e..7a9e947abd 100644 --- a/esphome/components/text_sensor/__init__.py +++ b/esphome/components/text_sensor/__init__.py @@ -110,17 +110,28 @@ def validate_mapping(value): "substitute", SubstituteFilter, cv.ensure_list(validate_mapping) ) async def substitute_filter_to_code(config, filter_id): - from_strings = [conf[CONF_FROM] for conf in config] - to_strings = [conf[CONF_TO] for conf in config] - return cg.new_Pvariable(filter_id, from_strings, to_strings) + substitutions = [ + 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, substitutions) @FILTER_REGISTRY.register("map", MapFilter, cv.ensure_list(validate_mapping)) async def map_filter_to_code(config, filter_id): - map_ = cg.std_ns.class_("map").template(cg.std_string, cg.std_string) - return cg.new_Pvariable( - filter_id, map_([(item[CONF_FROM], item[CONF_TO]) for item in config]) - ) + 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, mappings) 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 80edae2b6c..92cf8fdb9b 100644 --- a/esphome/components/text_sensor/filter.cpp +++ b/esphome/components/text_sensor/filter.cpp @@ -62,19 +62,36 @@ optional AppendFilter::new_value(std::string value) { return value optional PrependFilter::new_value(std::string value) { return this->prefix_ + value; } // Substitute +SubstituteFilter::SubstituteFilter(std::initializer_list substitutions) { + this->substitutions_.init(substitutions.size()); + for (auto &sub : substitutions) { + this->substitutions_.push_back(std::move(sub)); + } +} + optional SubstituteFilter::new_value(std::string value) { std::size_t pos; - for (size_t i = 0; i < this->from_strings_.size(); i++) { - while ((pos = value.find(this->from_strings_[i])) != std::string::npos) - value.replace(pos, this->from_strings_[i].size(), this->to_strings_[i]); + for (const auto &sub : this->substitutions_) { + while ((pos = value.find(sub.from)) != std::string::npos) + value.replace(pos, sub.from.size(), sub.to); } return value; } // Map +MapFilter::MapFilter(std::initializer_list mappings) { + this->mappings_.init(mappings.size()); + for (auto &mapping : mappings) { + this->mappings_.push_back(std::move(mapping)); + } +} + optional MapFilter::new_value(std::string value) { - auto item = mappings_.find(value); - return item == mappings_.end() ? value : item->second; + for (const auto &mapping : this->mappings_) { + if (mapping.from == value) + return mapping.to; + } + return value; // Pass through if no match } } // namespace text_sensor diff --git a/esphome/components/text_sensor/filter.h b/esphome/components/text_sensor/filter.h index 2de9010b88..fcb1c4b347 100644 --- a/esphome/components/text_sensor/filter.h +++ b/esphome/components/text_sensor/filter.h @@ -2,10 +2,6 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" -#include -#include -#include -#include namespace esphome { namespace text_sensor { @@ -98,26 +94,52 @@ class PrependFilter : public Filter { std::string prefix_; }; +struct Substitution { + std::string from; + std::string to; +}; + /// A simple filter that replaces a substring with another substring class SubstituteFilter : public Filter { public: - SubstituteFilter(std::vector from_strings, std::vector to_strings) - : from_strings_(std::move(from_strings)), to_strings_(std::move(to_strings)) {} + explicit SubstituteFilter(std::initializer_list substitutions); optional new_value(std::string value) override; protected: - std::vector from_strings_; - std::vector to_strings_; + FixedVector substitutions_; }; -/// A filter that maps values from one set to another +/** A filter that maps values from one set to another + * + * Uses linear search instead of std::map for typical small datasets (2-20 mappings). + * Linear search on contiguous memory is faster than red-black tree lookups when: + * - Dataset is small (< ~30 items) + * - Memory is contiguous (cache-friendly, better CPU cache utilization) + * - No pointer chasing overhead (tree node traversal) + * - String comparison cost dominates lookup time + * + * Benchmark results (see benchmark_map_filter.cpp): + * - 2 mappings: Linear 1.26x faster than std::map + * - 5 mappings: Linear 2.25x faster than std::map + * - 10 mappings: Linear 1.83x faster than std::map + * - 20 mappings: Linear 1.59x faster than std::map + * - 30 mappings: Linear 1.09x faster than std::map + * - 40 mappings: std::map 1.27x faster than Linear (break-even) + * + * Benefits over std::map: + * - ~2KB smaller flash (no red-black tree code) + * - ~24-32 bytes less RAM per mapping (no tree node overhead) + * - Faster for typical ESPHome usage (2-10 mappings common, 20+ rare) + * + * Break-even point: ~35-40 mappings, but ESPHome configs rarely exceed 20 + */ class MapFilter : public Filter { public: - MapFilter(std::map mappings) : mappings_(std::move(mappings)) {} + explicit MapFilter(std::initializer_list mappings); optional new_value(std::string value) override; protected: - std::map mappings_; + FixedVector mappings_; }; } // namespace text_sensor diff --git a/tests/components/text_sensor/common.yaml b/tests/components/text_sensor/common.yaml new file mode 100644 index 0000000000..4459c0fa44 --- /dev/null +++ b/tests/components/text_sensor/common.yaml @@ -0,0 +1,66 @@ +text_sensor: + - platform: template + name: "Test Substitute Single" + id: test_substitute_single + filters: + - substitute: + - ERROR -> Error + + - platform: template + name: "Test Substitute Multiple" + id: test_substitute_multiple + filters: + - substitute: + - ERROR -> Error + - WARN -> Warning + - INFO -> Information + - DEBUG -> Debug + + - platform: template + name: "Test Substitute Chained" + id: test_substitute_chained + filters: + - substitute: + - foo -> bar + - to_upper + - substitute: + - BAR -> baz + + - platform: template + name: "Test Map Single" + id: test_map_single + filters: + - map: + - ON -> Active + + - platform: template + name: "Test Map Multiple" + id: test_map_multiple + filters: + - map: + - ON -> Active + - OFF -> Inactive + - UNKNOWN -> Error + - IDLE -> Standby + + - platform: template + name: "Test Map Passthrough" + id: test_map_passthrough + filters: + - map: + - Good -> Excellent + - Bad -> Poor + + - platform: template + name: "Test All Filters" + id: test_all_filters + filters: + - to_upper + - to_lower + - append: " suffix" + - prepend: "prefix " + - substitute: + - prefix -> PREFIX + - suffix -> SUFFIX + - map: + - PREFIX text SUFFIX -> mapped diff --git a/tests/components/text_sensor/test.esp8266-ard.yaml b/tests/components/text_sensor/test.esp8266-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/text_sensor/test.esp8266-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml