diff --git a/esphome/components/template/select/__init__.py b/esphome/components/template/select/__init__.py index 574f1f5fb7..8de0b65c2d 100644 --- a/esphome/components/template/select/__init__.py +++ b/esphome/components/template/select/__init__.py @@ -10,35 +10,65 @@ from esphome.const import ( CONF_OPTIONS, CONF_RESTORE_VALUE, CONF_SET_ACTION, + CONF_UPDATE_INTERVAL, + SCHEDULER_DONT_RUN, ) +from esphome.core import TimePeriodMilliseconds +from esphome.cpp_generator import TemplateArguments from .. import template_ns TemplateSelect = template_ns.class_( "TemplateSelect", select.Select, cg.PollingComponent ) +TemplateSelectWithSetAction = template_ns.class_( + "TemplateSelectWithSetAction", TemplateSelect +) def validate(config): + errors = [] if CONF_LAMBDA in config: if config[CONF_OPTIMISTIC]: - raise cv.Invalid("optimistic cannot be used with lambda") + errors.append( + cv.Invalid( + "optimistic cannot be used with lambda", path=[CONF_OPTIMISTIC] + ) + ) if CONF_INITIAL_OPTION in config: - raise cv.Invalid("initial_value cannot be used with lambda") + errors.append( + cv.Invalid( + "initial_value cannot be used with lambda", + path=[CONF_INITIAL_OPTION], + ) + ) if CONF_RESTORE_VALUE in config: - raise cv.Invalid("restore_value cannot be used with lambda") + errors.append( + cv.Invalid( + "restore_value cannot be used with lambda", + path=[CONF_RESTORE_VALUE], + ) + ) elif CONF_INITIAL_OPTION in config: if config[CONF_INITIAL_OPTION] not in config[CONF_OPTIONS]: - raise cv.Invalid( - f"initial_option '{config[CONF_INITIAL_OPTION]}' is not a valid option [{', '.join(config[CONF_OPTIONS])}]" + errors.append( + cv.Invalid( + f"initial_option '{config[CONF_INITIAL_OPTION]}' is not a valid option [{', '.join(config[CONF_OPTIONS])}]", + path=[CONF_INITIAL_OPTION], + ) ) else: config[CONF_INITIAL_OPTION] = config[CONF_OPTIONS][0] if not config[CONF_OPTIMISTIC] and CONF_SET_ACTION not in config: - raise cv.Invalid( - "Either optimistic mode must be enabled, or set_action must be set, to handle the option being set." + errors.append( + cv.Invalid( + "Either optimistic mode must be enabled, or set_action must be set, to handle the option being set." + ) ) + if errors: + raise cv.MultipleInvalid(errors) + return config @@ -62,29 +92,34 @@ CONFIG_SCHEMA = cv.All( async def to_code(config): - var = cg.new_Pvariable(config[CONF_ID]) - await cg.register_component(var, config) - await select.register_select(var, config, options=config[CONF_OPTIONS]) + var_id = config[CONF_ID] + if CONF_SET_ACTION in config: + var_id.type = TemplateSelectWithSetAction + has_lambda = CONF_LAMBDA in config + optimistic = config.get(CONF_OPTIMISTIC, False) + restore_value = config.get(CONF_RESTORE_VALUE, False) + options = config[CONF_OPTIONS] + initial_option = config.get(CONF_INITIAL_OPTION, 0) + initial_option_index = options.index(initial_option) if not has_lambda else 0 + + var = cg.new_Pvariable( + var_id, + TemplateArguments(has_lambda, optimistic, restore_value, initial_option_index), + ) + component_config = config.copy() + if not has_lambda: + # No point in polling if not using a lambda + component_config[CONF_UPDATE_INTERVAL] = TimePeriodMilliseconds( + milliseconds=SCHEDULER_DONT_RUN + ) + await cg.register_component(var, component_config) + await select.register_select(var, config, options=options) if CONF_LAMBDA in config: - template_ = await cg.process_lambda( + lambda_ = await cg.process_lambda( config[CONF_LAMBDA], [], return_type=cg.optional.template(cg.std_string) ) - cg.add(var.set_template(template_)) - - else: - # Only set if non-default to avoid bloating setup() function - if config[CONF_OPTIMISTIC]: - cg.add(var.set_optimistic(True)) - initial_option_index = config[CONF_OPTIONS].index(config[CONF_INITIAL_OPTION]) - # Only set if non-zero to avoid bloating setup() function - # (initial_option_index_ is zero-initialized in the header) - if initial_option_index != 0: - cg.add(var.set_initial_option_index(initial_option_index)) - - # Only set if True (default is False) - if config.get(CONF_RESTORE_VALUE): - cg.add(var.set_restore_value(True)) + cg.add(var.set_lambda(lambda_)) if CONF_SET_ACTION in config: await automation.build_automation( diff --git a/esphome/components/template/select/template_select.cpp b/esphome/components/template/select/template_select.cpp index fa34aa9fa7..e68729c2d4 100644 --- a/esphome/components/template/select/template_select.cpp +++ b/esphome/components/template/select/template_select.cpp @@ -5,61 +5,44 @@ namespace esphome::template_ { static const char *const TAG = "template.select"; -void TemplateSelect::setup() { - if (this->f_.has_value()) - return; - - size_t index = this->initial_option_index_; - if (this->restore_value_) { - this->pref_ = this->make_entity_preference(); - size_t restored_index; - if (this->pref_.load(&restored_index) && this->has_index(restored_index)) { - index = restored_index; - ESP_LOGD(TAG, "State from restore: %s", this->option_at(index)); - } else { - ESP_LOGD(TAG, "State from initial (could not load or invalid stored index): %s", this->option_at(index)); - } +void dump_config_helper(BaseTemplateSelect *sel_comp, bool optimistic, bool has_lambda, + const size_t initial_option_index, bool restore_value) { + LOG_SELECT("", "Template Select", sel_comp); + if (has_lambda) { + LOG_UPDATE_INTERVAL(sel_comp); } else { - ESP_LOGD(TAG, "State from initial: %s", this->option_at(index)); + ESP_LOGCONFIG(TAG, + " Optimistic: %s\n" + " Initial Option: %s\n" + " Restore Value: %s", + YESNO(optimistic), sel_comp->option_at(initial_option_index), YESNO(restore_value)); } - - this->publish_state(index); } -void TemplateSelect::update() { - if (!this->f_.has_value()) - return; +void setup_initial(BaseTemplateSelect *sel_comp, size_t initial_index) { + ESP_LOGD(TAG, "State from initial: %s", sel_comp->option_at(initial_index)); + sel_comp->publish_state(initial_index); +} - auto val = this->f_(); +void setup_with_restore(BaseTemplateSelect *sel_comp, ESPPreferenceObject &pref, size_t initial_index) { + size_t index = initial_index; + if (pref.load(&index) && sel_comp->has_index(index)) { + ESP_LOGD(TAG, "State from restore: %s", sel_comp->option_at(index)); + } else { + index = initial_index; + ESP_LOGD(TAG, "State from initial (no valid stored index): %s", sel_comp->option_at(initial_index)); + } + sel_comp->publish_state(index); +} + +void update_lambda(BaseTemplateSelect *sel_comp, const optional &val) { if (val.has_value()) { - if (!this->has_option(*val)) { + if (!sel_comp->has_option(*val)) { ESP_LOGE(TAG, "Lambda returned an invalid option: %s", (*val).c_str()); return; } - this->publish_state(*val); + sel_comp->publish_state(*val); } } -void TemplateSelect::control(size_t index) { - this->set_trigger_->trigger(StringRef(this->option_at(index))); - - if (this->optimistic_) - this->publish_state(index); - - if (this->restore_value_) - this->pref_.save(&index); -} - -void TemplateSelect::dump_config() { - LOG_SELECT("", "Template Select", this); - LOG_UPDATE_INTERVAL(this); - if (this->f_.has_value()) - return; - ESP_LOGCONFIG(TAG, - " Optimistic: %s\n" - " Initial Option: %s\n" - " Restore Value: %s", - YESNO(this->optimistic_), this->option_at(this->initial_option_index_), YESNO(this->restore_value_)); -} - } // namespace esphome::template_ diff --git a/esphome/components/template/select/template_select.h b/esphome/components/template/select/template_select.h index 114d25b9ce..5da6d732bd 100644 --- a/esphome/components/template/select/template_select.h +++ b/esphome/components/template/select/template_select.h @@ -9,29 +9,71 @@ namespace esphome::template_ { -class TemplateSelect final : public select::Select, public PollingComponent { - public: - template void set_template(F &&f) { this->f_.set(std::forward(f)); } +struct Empty {}; +class BaseTemplateSelect : public select::Select, public PollingComponent {}; - void setup() override; - void update() override; - void dump_config() override; +void dump_config_helper(BaseTemplateSelect *sel_comp, bool optimistic, bool has_lambda, size_t initial_option_index, + bool restore_value); +void setup_initial(BaseTemplateSelect *sel_comp, size_t initial_index); +void setup_with_restore(BaseTemplateSelect *sel_comp, ESPPreferenceObject &pref, size_t initial_index); +void update_lambda(BaseTemplateSelect *sel_comp, const optional &val); + +/// Base template select class - used when no set_action is configured + +template +class TemplateSelect : public BaseTemplateSelect { + public: + template void set_lambda(F &&f) { + if constexpr (HAS_LAMBDA) { + this->f_.set(std::forward(f)); + } + } + + void setup() override { + if constexpr (!HAS_LAMBDA) { + if constexpr (RESTORE_VALUE) { + this->pref_ = this->template make_entity_preference(); + setup_with_restore(this, this->pref_, INITIAL_OPTION_INDEX); + } else { + setup_initial(this, INITIAL_OPTION_INDEX); + } + } + } + + void update() override { + if constexpr (HAS_LAMBDA) { + update_lambda(this, this->f_()); + } + } + void dump_config() override { + dump_config_helper(this, OPTIMISTIC, HAS_LAMBDA, INITIAL_OPTION_INDEX, RESTORE_VALUE); + }; float get_setup_priority() const override { return setup_priority::HARDWARE; } - Trigger *get_set_trigger() const { return this->set_trigger_; } - void set_optimistic(bool optimistic) { this->optimistic_ = optimistic; } - void set_initial_option_index(size_t initial_option_index) { this->initial_option_index_ = initial_option_index; } - void set_restore_value(bool restore_value) { this->restore_value_ = restore_value; } + protected: + void control(size_t index) override { + if constexpr (OPTIMISTIC) + this->publish_state(index); + if constexpr (RESTORE_VALUE) + this->pref_.save(&index); + } + [[no_unique_address]] std::conditional_t, Empty> f_{}; + [[no_unique_address]] std::conditional_t pref_{}; +}; + +/// Template select with set_action trigger - only instantiated when set_action is configured +template +class TemplateSelectWithSetAction final + : public TemplateSelect { + public: + Trigger *get_set_trigger() { return &this->set_trigger_; } protected: - void control(size_t index) override; - bool optimistic_ = false; - size_t initial_option_index_{0}; - bool restore_value_ = false; - Trigger *set_trigger_ = new Trigger(); - TemplateLambda f_; - - ESPPreferenceObject pref_; + void control(size_t index) override { + this->set_trigger_.trigger(StringRef(this->option_at(index))); + TemplateSelect::control(index); + } + Trigger set_trigger_; }; } // namespace esphome::template_ diff --git a/tests/integration/fixtures/select_stringref_trigger.yaml b/tests/integration/fixtures/select_stringref_trigger.yaml index bb1e1fd843..5858e2f529 100644 --- a/tests/integration/fixtures/select_stringref_trigger.yaml +++ b/tests/integration/fixtures/select_stringref_trigger.yaml @@ -56,7 +56,21 @@ select: std::string prefix = x.substr(0, 6); ESP_LOGI("test", "Substr prefix: %s", prefix.c_str()); - # Second select with numeric options to test ADL functions + # Second select with set_action trigger (uses TemplateSelectWithSetAction subclass) + - platform: template + name: "Action Select" + id: action_select + options: + - "Action A" + - "Action B" + set_action: + then: + # Test: set_action trigger receives StringRef + - logger.log: + format: "set_action triggered: %s" + args: ['x.c_str()'] + + # Third select with numeric options to test ADL functions - platform: template name: "Baud Rate" id: baud_select diff --git a/tests/integration/test_select_stringref_trigger.py b/tests/integration/test_select_stringref_trigger.py index 7fc72a2290..5baba9c7f5 100644 --- a/tests/integration/test_select_stringref_trigger.py +++ b/tests/integration/test_select_stringref_trigger.py @@ -28,6 +28,8 @@ async def test_select_stringref_trigger( find_substr_future = loop.create_future() find_char_future = loop.create_future() substr_future = loop.create_future() + # set_action trigger (TemplateSelectWithSetAction subclass) + set_action_future = loop.create_future() # ADL functions stoi_future = loop.create_future() stol_future = loop.create_future() @@ -43,6 +45,8 @@ async def test_select_stringref_trigger( find_substr_pattern = re.compile(r"Found 'Option' in value") find_char_pattern = re.compile(r"Space at position: 6") # space at index 6 substr_pattern = re.compile(r"Substr prefix: Option") + # set_action trigger pattern (TemplateSelectWithSetAction subclass) + set_action_pattern = re.compile(r"set_action triggered: Action B") # ADL function patterns (115200 from baud rate select) stoi_pattern = re.compile(r"stoi result: 115200") stol_pattern = re.compile(r"stol result: 115200") @@ -67,6 +71,9 @@ async def test_select_stringref_trigger( find_char_future.set_result(True) if not substr_future.done() and substr_pattern.search(line): substr_future.set_result(True) + # set_action trigger + if not set_action_future.done() and set_action_pattern.search(line): + set_action_future.set_result(True) # ADL functions if not stoi_future.done() and stoi_pattern.search(line): stoi_future.set_result(True) @@ -89,22 +96,21 @@ async def test_select_stringref_trigger( # List entities to find our select entities, _ = await client.list_entities_services() - select_entity = next( - (e for e in entities if hasattr(e, "options") and e.name == "Test Select"), - None, - ) + select_entity = next((e for e in entities if e.name == "Test Select"), None) assert select_entity is not None, "Test Select entity not found" - baud_entity = next( - (e for e in entities if hasattr(e, "options") and e.name == "Baud Rate"), - None, - ) + baud_entity = next((e for e in entities if e.name == "Baud Rate"), None) assert baud_entity is not None, "Baud Rate entity not found" + action_entity = next((e for e in entities if e.name == "Action Select"), None) + assert action_entity is not None, "Action Select entity not found" + # Change select to Option B - this should trigger on_value with StringRef client.select_command(select_entity.key, "Option B") # Change baud to 115200 - this tests ADL functions (stoi, stol, stof, stod) client.select_command(baud_entity.key, "115200") + # Change action select - tests set_action trigger (TemplateSelectWithSetAction) + client.select_command(action_entity.key, "Action B") # Wait for all log messages confirming StringRef operations work try: @@ -118,6 +124,7 @@ async def test_select_stringref_trigger( find_substr_future, find_char_future, substr_future, + set_action_future, stoi_future, stol_future, stof_future, @@ -135,6 +142,7 @@ async def test_select_stringref_trigger( "find_substr": find_substr_future.done(), "find_char": find_char_future.done(), "substr": substr_future.done(), + "set_action": set_action_future.done(), "stoi": stoi_future.done(), "stol": stol_future.done(), "stof": stof_future.done(),