From 4a579700a014f7beb2caebb93a21e64da23d5ff1 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Feb 2026 06:52:14 +1100 Subject: [PATCH] [cover] Add operation-based triggers and fix repeated trigger firing (#13471) --- esphome/components/cover/__init__.py | 83 ++++++++++++++++------ esphome/components/cover/automation.h | 53 ++++++++------ esphome/components/cover/cover.cpp | 3 - esphome/components/cover/cover.h | 4 +- tests/components/template/common-base.yaml | 38 ++++++++++ 5 files changed, 133 insertions(+), 48 deletions(-) diff --git a/esphome/components/cover/__init__.py b/esphome/components/cover/__init__.py index 383daee083a..41774f3d71a 100644 --- a/esphome/components/cover/__init__.py +++ b/esphome/components/cover/__init__.py @@ -1,3 +1,5 @@ +import logging + from esphome import automation from esphome.automation import Condition, maybe_simple_id import esphome.codegen as cg @@ -9,6 +11,7 @@ from esphome.const import ( CONF_ICON, CONF_ID, CONF_MQTT_ID, + CONF_ON_IDLE, CONF_ON_OPEN, CONF_POSITION, CONF_POSITION_COMMAND_TOPIC, @@ -32,9 +35,10 @@ from esphome.const import ( DEVICE_CLASS_SHUTTER, DEVICE_CLASS_WINDOW, ) -from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity -from esphome.cpp_generator import MockObjClass +from esphome.cpp_generator import MockObj, MockObjClass +from esphome.types import ConfigType, TemplateArgsType IS_PLATFORM_COMPONENT = True @@ -53,6 +57,8 @@ DEVICE_CLASSES = [ DEVICE_CLASS_WINDOW, ] +_LOGGER = logging.getLogger(__name__) + cover_ns = cg.esphome_ns.namespace("cover") Cover = cover_ns.class_("Cover", cg.EntityBase) @@ -83,14 +89,29 @@ ControlAction = cover_ns.class_("ControlAction", automation.Action) CoverPublishAction = cover_ns.class_("CoverPublishAction", automation.Action) CoverIsOpenCondition = cover_ns.class_("CoverIsOpenCondition", Condition) CoverIsClosedCondition = cover_ns.class_("CoverIsClosedCondition", Condition) - -# Triggers -CoverOpenTrigger = cover_ns.class_("CoverOpenTrigger", automation.Trigger.template()) +CoverOpenedTrigger = cover_ns.class_( + "CoverOpenedTrigger", automation.Trigger.template() +) CoverClosedTrigger = cover_ns.class_( "CoverClosedTrigger", automation.Trigger.template() ) +CoverTrigger = cover_ns.class_("CoverTrigger", automation.Trigger.template()) +# Cover-specific constants CONF_ON_CLOSED = "on_closed" +CONF_ON_OPENED = "on_opened" +CONF_ON_OPENING = "on_opening" +CONF_ON_CLOSING = "on_closing" + +TRIGGERS = { + CONF_ON_OPEN: CoverOpenedTrigger, # Deprecated, use on_opened + CONF_ON_OPENED: CoverOpenedTrigger, + CONF_ON_CLOSED: CoverClosedTrigger, + CONF_ON_CLOSING: CoverTrigger.template(CoverOperation.COVER_OPERATION_CLOSING), + CONF_ON_OPENING: CoverTrigger.template(CoverOperation.COVER_OPERATION_OPENING), + CONF_ON_IDLE: CoverTrigger.template(CoverOperation.COVER_OPERATION_IDLE), +} + _COVER_SCHEMA = ( cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA) @@ -111,16 +132,14 @@ _COVER_SCHEMA = ( cv.Optional(CONF_TILT_STATE_TOPIC): cv.All( cv.requires_component("mqtt"), cv.subscribe_topic ), - cv.Optional(CONF_ON_OPEN): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(CoverOpenTrigger), - } - ), - cv.Optional(CONF_ON_CLOSED): automation.validate_automation( - { - cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(CoverClosedTrigger), - } - ), + **{ + cv.Optional(conf): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(trigger_class), + } + ) + for conf, trigger_class in TRIGGERS.items() + }, } ) ) @@ -157,12 +176,14 @@ async def setup_cover_core_(var, config): if (device_class := config.get(CONF_DEVICE_CLASS)) is not None: cg.add(var.set_device_class(device_class)) - for conf in config.get(CONF_ON_OPEN, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) - for conf in config.get(CONF_ON_CLOSED, []): - trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) - await automation.build_automation(trigger, [], conf) + if CONF_ON_OPEN in config: + _LOGGER.warning( + "'on_open' is deprecated, use 'on_opened'. Will be removed in 2026.8.0" + ) + for trigger_conf in TRIGGERS: + for conf in config.get(trigger_conf, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [], conf) if (mqtt_id := config.get(CONF_MQTT_ID)) is not None: mqtt_ = cg.new_Pvariable(mqtt_id, var) @@ -258,6 +279,26 @@ async def cover_control_to_code(config, action_id, template_arg, args): return var +COVER_CONDITION_SCHEMA = cv.maybe_simple_value( + {cv.Required(CONF_ID): cv.use_id(Cover)}, key=CONF_ID +) + + +async def cover_condition_to_code( + config: ConfigType, condition_id: ID, template_arg: MockObj, args: TemplateArgsType +) -> MockObj: + paren = await cg.get_variable(config[CONF_ID]) + return cg.new_Pvariable(condition_id, template_arg, paren) + + +automation.register_condition( + "cover.is_open", CoverIsOpenCondition, COVER_CONDITION_SCHEMA +)(cover_condition_to_code) +automation.register_condition( + "cover.is_closed", CoverIsClosedCondition, COVER_CONDITION_SCHEMA +)(cover_condition_to_code) + + @coroutine_with_priority(CoroPriority.CORE) async def to_code(config): cg.add_global(cover_ns.using) diff --git a/esphome/components/cover/automation.h b/esphome/components/cover/automation.h index c0345a7cc61..12ec46725d6 100644 --- a/esphome/components/cover/automation.h +++ b/esphome/components/cover/automation.h @@ -90,44 +90,53 @@ template class CoverPublishAction : public Action { Cover *cover_; }; -template class CoverIsOpenCondition : public Condition { +template class CoverPositionCondition : public Condition { public: - CoverIsOpenCondition(Cover *cover) : cover_(cover) {} - bool check(const Ts &...x) override { return this->cover_->is_fully_open(); } + CoverPositionCondition(Cover *cover) : cover_(cover) {} + + bool check(const Ts &...x) override { return this->cover_->position == (OPEN ? COVER_OPEN : COVER_CLOSED); } protected: Cover *cover_; }; -template class CoverIsClosedCondition : public Condition { +template using CoverIsOpenCondition = CoverPositionCondition; +template using CoverIsClosedCondition = CoverPositionCondition; + +template class CoverPositionTrigger : public Trigger<> { public: - CoverIsClosedCondition(Cover *cover) : cover_(cover) {} - bool check(const Ts &...x) override { return this->cover_->is_fully_closed(); } + CoverPositionTrigger(Cover *a_cover) { + a_cover->add_on_state_callback([this, a_cover]() { + if (a_cover->position != this->last_position_) { + this->last_position_ = a_cover->position; + if (a_cover->position == (OPEN ? COVER_OPEN : COVER_CLOSED)) + this->trigger(); + } + }); + } protected: - Cover *cover_; + float last_position_{NAN}; }; -class CoverOpenTrigger : public Trigger<> { +using CoverOpenedTrigger = CoverPositionTrigger; +using CoverClosedTrigger = CoverPositionTrigger; + +template class CoverTrigger : public Trigger<> { public: - CoverOpenTrigger(Cover *a_cover) { + CoverTrigger(Cover *a_cover) { a_cover->add_on_state_callback([this, a_cover]() { - if (a_cover->is_fully_open()) { - this->trigger(); + auto current_op = a_cover->current_operation; + if (current_op == OP) { + if (!this->last_operation_.has_value() || this->last_operation_.value() != OP) { + this->trigger(); + } } + this->last_operation_ = current_op; }); } -}; -class CoverClosedTrigger : public Trigger<> { - public: - CoverClosedTrigger(Cover *a_cover) { - a_cover->add_on_state_callback([this, a_cover]() { - if (a_cover->is_fully_closed()) { - this->trigger(); - } - }); - } + protected: + optional last_operation_{}; }; - } // namespace esphome::cover diff --git a/esphome/components/cover/cover.cpp b/esphome/components/cover/cover.cpp index 0d9e7e8ffb0..37cb908d9f3 100644 --- a/esphome/components/cover/cover.cpp +++ b/esphome/components/cover/cover.cpp @@ -10,9 +10,6 @@ namespace esphome::cover { static const char *const TAG = "cover"; -const float COVER_OPEN = 1.0f; -const float COVER_CLOSED = 0.0f; - const LogString *cover_command_to_str(float pos) { if (pos == COVER_OPEN) { return LOG_STR("OPEN"); diff --git a/esphome/components/cover/cover.h b/esphome/components/cover/cover.h index e5427ceaa86..0af48f75de8 100644 --- a/esphome/components/cover/cover.h +++ b/esphome/components/cover/cover.h @@ -10,8 +10,8 @@ namespace esphome::cover { -const extern float COVER_OPEN; -const extern float COVER_CLOSED; +static constexpr float COVER_OPEN = 1.0f; +static constexpr float COVER_CLOSED = 0.0f; #define LOG_COVER(prefix, type, obj) \ if ((obj) != nullptr) { \ diff --git a/tests/components/template/common-base.yaml b/tests/components/template/common-base.yaml index 9dece7c3a59..d1849efaf75 100644 --- a/tests/components/template/common-base.yaml +++ b/tests/components/template/common-base.yaml @@ -245,6 +245,44 @@ cover: stop_action: - logger.log: stop_action optimistic: true + - platform: template + name: "Template Cover with Triggers" + id: template_cover_with_triggers + lambda: |- + if (id(some_binary_sensor).state) { + return COVER_OPEN; + } + return COVER_CLOSED; + open_action: + - logger.log: open_action + close_action: + - logger.log: close_action + stop_action: + - logger.log: stop_action + optimistic: true + on_open: + - logger.log: "Cover on_open (deprecated)" + on_opened: + - logger.log: "Cover fully opened" + on_closed: + - logger.log: "Cover fully closed" + on_opening: + - logger.log: "Cover started opening" + on_closing: + - logger.log: "Cover started closing" + on_idle: + - logger.log: "Cover stopped moving" + - logger.log: "Cover stopped moving" + - if: + condition: + cover.is_open: template_cover_with_triggers + then: + logger.log: Cover is open + - if: + condition: + cover.is_closed: template_cover_with_triggers + then: + logger.log: Cover is closed number: - platform: template