diff --git a/CODEOWNERS b/CODEOWNERS index c630db7948..63d76c055c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -368,6 +368,7 @@ esphome/components/template/fan/* @ssieb esphome/components/text/* @mauritskorse esphome/components/thermostat/* @kbx81 esphome/components/time/* @OttoWinter +esphome/components/time_based_tilt/* @klaudiusz223 esphome/components/tlc5947/* @rnauber esphome/components/tlc5971/* @IJIJI esphome/components/tm1621/* @Philippe12 diff --git a/esphome/components/time_based_tilt/__init__.py b/esphome/components/time_based_tilt/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/components/time_based_tilt/cover.py b/esphome/components/time_based_tilt/cover.py new file mode 100644 index 0000000000..d3ca1ef69e --- /dev/null +++ b/esphome/components/time_based_tilt/cover.py @@ -0,0 +1,85 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome import automation +from esphome.components import cover +from esphome.const import ( + CONF_CLOSE_ACTION, + CONF_CLOSE_DURATION, + CONF_ID, + CONF_OPEN_ACTION, + CONF_OPEN_DURATION, + CONF_STOP_ACTION, + CONF_ASSUMED_STATE, +) + +CODEOWNERS = ["@klaudiusz223"] + +time_based_tilt_ns = cg.esphome_ns.namespace("time_based_tilt") +TimeBasedTiltCover = time_based_tilt_ns.class_( + "TimeBasedTiltCover", cover.Cover, cg.Component +) + +CONF_TILT_OPEN_DURATION = "tilt_open_duration" +CONF_TILT_CLOSE_DURATION = "tilt_close_duration" +CONF_INTERLOCK_WAIT_TIME = "interlock_wait_time" +CONF_RECALIBRATION_TIME = "recalibration_time" +CONF_INERTIA_OPEN_TIME = "inertia_open_time" +CONF_INERTIA_CLOSE_TIME = "inertia_close_time" + +CONFIG_SCHEMA = cover.COVER_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(TimeBasedTiltCover), + cv.Required(CONF_STOP_ACTION): automation.validate_automation(single=True), + cv.Required(CONF_OPEN_ACTION): automation.validate_automation(single=True), + cv.Required(CONF_OPEN_DURATION): cv.positive_time_period_milliseconds, + cv.Required(CONF_CLOSE_ACTION): automation.validate_automation(single=True), + cv.Required(CONF_CLOSE_DURATION): cv.positive_time_period_milliseconds, + cv.Optional(CONF_ASSUMED_STATE, default=True): cv.boolean, + cv.Optional( + CONF_TILT_OPEN_DURATION, default="0ms" + ): cv.positive_time_period_milliseconds, + cv.Optional( + CONF_TILT_CLOSE_DURATION, default="0ms" + ): cv.positive_time_period_milliseconds, + cv.Optional( + CONF_INTERLOCK_WAIT_TIME, default="0ms" + ): cv.positive_time_period_milliseconds, + cv.Optional( + CONF_RECALIBRATION_TIME, default="0ms" + ): cv.positive_time_period_milliseconds, + cv.Optional( + CONF_INERTIA_OPEN_TIME, default="0ms" + ): cv.positive_time_period_milliseconds, + cv.Optional( + CONF_INERTIA_CLOSE_TIME, default="0ms" + ): cv.positive_time_period_milliseconds, + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await cover.register_cover(var, config) + + await automation.build_automation( + var.get_stop_trigger(), [], config[CONF_STOP_ACTION] + ) + + cg.add(var.set_open_duration(config[CONF_OPEN_DURATION])) + await automation.build_automation( + var.get_open_trigger(), [], config[CONF_OPEN_ACTION] + ) + + cg.add(var.set_close_duration(config[CONF_CLOSE_DURATION])) + await automation.build_automation( + var.get_close_trigger(), [], config[CONF_CLOSE_ACTION] + ) + + cg.add(var.set_tilt_open_duration(config[CONF_TILT_OPEN_DURATION])) + cg.add(var.set_tilt_close_duration(config[CONF_TILT_CLOSE_DURATION])) + cg.add(var.set_interlock_wait_time(config[CONF_INTERLOCK_WAIT_TIME])) + cg.add(var.set_recalibration_time(config[CONF_RECALIBRATION_TIME])) + cg.add(var.set_inertia_close_time(config[CONF_INERTIA_CLOSE_TIME])) + cg.add(var.set_inertia_open_time(config[CONF_INERTIA_OPEN_TIME])) + cg.add(var.set_assumed_state(config[CONF_ASSUMED_STATE])) diff --git a/esphome/components/time_based_tilt/time_based_tilt_cover.cpp b/esphome/components/time_based_tilt/time_based_tilt_cover.cpp new file mode 100644 index 0000000000..dde95891b9 --- /dev/null +++ b/esphome/components/time_based_tilt/time_based_tilt_cover.cpp @@ -0,0 +1,321 @@ +#include "time_based_tilt_cover.h" +#include "esphome/core/log.h" +#include "esphome/core/hal.h" + +namespace esphome { +namespace time_based_tilt { + +static const char *const TAG = "time_based_tilt.cover"; + +const float TimeBasedTiltCover::TARGET_NONE = -1; + +using namespace esphome::cover; + +void TimeBasedTiltCover::dump_config() { + LOG_COVER("", "Time Based Tilt Cover", this); + ESP_LOGCONFIG(TAG, " Open Duration: %.3fs", this->open_duration_ / 1e3f); + ESP_LOGCONFIG(TAG, " Close Duration: %.3fs", this->close_duration_ / 1e3f); + ESP_LOGCONFIG(TAG, " Tilt Close Duration: %.3fs", this->tilt_close_duration_ / 1e3f); + ESP_LOGCONFIG(TAG, " Tilt Open Duration: %.3fs", this->tilt_open_duration_ / 1e3f); + ESP_LOGCONFIG(TAG, " Interlock wait time: %.3fs", this->interlock_wait_time_ / 1e3f); + ESP_LOGCONFIG(TAG, " Inertia close time: %.3fs", this->inertia_close_time_ / 1e3f); + ESP_LOGCONFIG(TAG, " Inertia open time: %.3fs", this->inertia_open_time_ / 1e3f); + ESP_LOGCONFIG(TAG, " Recalibration time: %.3fs", this->recalibration_time_ / 1e3f); +} +void TimeBasedTiltCover::setup() { + if (this->tilt_close_duration_ == 0 || this->tilt_open_duration_ == 0) { + this->tilt_close_duration_ = 0; + this->tilt_open_duration_ = 0; + } + auto restore = this->restore_state_(); + if (restore.has_value()) { + restore->apply(this); + } else { + this->position = 0.5f; + this->tilt = 0.5f; + } +} +bool TimeBasedTiltCover::is_at_target_position_() const { + switch (this->current_operation) { + case COVER_OPERATION_OPENING: + return this->position >= this->target_position_; + case COVER_OPERATION_CLOSING: + return this->position <= this->target_position_; + case COVER_OPERATION_IDLE: + default: + return true; + } +} + +bool TimeBasedTiltCover::is_at_target_tilt_() const { + switch (this->current_operation) { + case COVER_OPERATION_OPENING: + return this->tilt >= this->target_tilt_; + case COVER_OPERATION_CLOSING: + return this->tilt <= this->target_tilt_; + case COVER_OPERATION_IDLE: + default: + return true; + } +} + +void TimeBasedTiltCover::loop() { + if (this->fsm_state_ == STATE_IDLE && this->target_position_ == TARGET_NONE && this->target_tilt_ == TARGET_NONE) + return; + + const uint32_t now = millis(); + + // recalibrating in extreme postions + if (this->fsm_state_ == STATE_CALIBRATING) { + if (now - this->last_recompute_time_ >= this->recalibration_time_) { + this->fsm_state_ = STATE_STOPPING; + } + return; + } + + // STOPPING - determining the direction of the last movement and the stopping time. Needed to support interlocking + if (this->fsm_state_ == STATE_STOPPING) { + this->stop_trigger_->trigger(); + if (this->current_operation != COVER_OPERATION_IDLE) { + this->interlocked_time_ = millis(); + this->interlocked_direction_ = + this->current_operation == COVER_OPERATION_CLOSING ? COVER_OPERATION_OPENING : COVER_OPERATION_CLOSING; + } else { + this->interlocked_direction_ = COVER_OPERATION_IDLE; + } + this->fsm_state_ = STATE_IDLE; + this->last_operation_ = this->current_operation; + this->current_operation = COVER_OPERATION_IDLE; + this->publish_state(); + return; + } + + // if the cover is not moving, check whether the new targets are set and if they are, compute move direction + if (this->fsm_state_ == STATE_IDLE && (this->target_position_ != TARGET_NONE || this->target_tilt_ != TARGET_NONE)) { + if (this->target_position_ != TARGET_NONE) { + this->current_operation = this->compute_direction(this->target_position_, this->position); + } else { + this->current_operation = this->compute_direction(this->target_tilt_, this->tilt); + } + // interlocking support + if (this->current_operation == this->interlocked_direction_ && + now - this->interlocked_time_ < this->interlock_wait_time_) + return; + + Trigger<> *trig = this->current_operation == COVER_OPERATION_CLOSING ? this->close_trigger_ : this->open_trigger_; + + trig->trigger(); + this->last_recompute_time_ = now; + + this->fsm_state_ = STATE_MOVING; + return; + } + + // moving state + if (this->fsm_state_ == STATE_MOVING) { + auto travel_time = now - this->last_recompute_time_; + this->last_recompute_time_ = now; + + float dir_factor = this->current_operation == COVER_OPERATION_CLOSING ? -1.0 : 1.0; + auto inertia_time = + this->current_operation == COVER_OPERATION_CLOSING ? this->inertia_close_time_ : this->inertia_open_time_; + + if (inertia_time > 0 && this->inertia_ * dir_factor < 0.5f) { // inertia before movement + auto inertia_step = dir_factor * travel_time / inertia_time; + this->inertia_ += inertia_step; + auto rest = this->inertia_ - clamp(this->inertia_, -0.5f, 0.5f); + this->inertia_ = clamp(this->inertia_, -0.5f, 0.5f); + + if (!rest) + return; // the movement has not yet actually started + travel_time = dir_factor * rest * inertia_time; // actual movement time taking inertia into account + } + + auto tilt_time = + this->current_operation == COVER_OPERATION_CLOSING ? this->tilt_close_duration_ : this->tilt_open_duration_; + + if (tilt_time > 0 && (this->tilt - 0.5f) * dir_factor < 0.5f) { // tilting before movement + auto tilt_step = dir_factor * travel_time / tilt_time; + this->tilt += tilt_step; + auto rest = this->tilt - 0.5f - clamp(this->tilt - 0.5f, -0.5f, 0.5f); + this->tilt = clamp(this->tilt, 0.0f, 1.0f); + + if (this->target_position_ == TARGET_NONE && this->is_at_target_tilt_()) { // only tilting w/o position change + this->last_recompute_time_ = now; + this->target_tilt_ = TARGET_NONE; + this->last_publish_time_ = now; + + // If the cover is in extreme positions, run recalibration + if (this->recalibration_time_ > 0 && + (((this->position == COVER_CLOSED && (tilt_time == 0 || this->tilt == COVER_CLOSED)) || + (this->position == COVER_OPEN && (tilt_time == 0 || this->tilt == COVER_OPEN))))) { + this->fsm_state_ = STATE_CALIBRATING; + this->publish_state(false); + } else { + this->fsm_state_ = STATE_STOPPING; + } + + return; // only tilting w/o position change so no need to recompute position + } + + if (now - this->last_publish_time_ > ((tilt_time / 5) > 1000 ? 1000 : (tilt_time / 5))) { + this->publish_state(false); + this->last_publish_time_ = now; + } + + if (!rest) + return; // the movement has not yet actually started + + travel_time = dir_factor * rest * tilt_time; // actual movement time taking tilt into account + } + + auto move_time = this->current_operation == COVER_OPERATION_CLOSING ? this->close_duration_ : this->open_duration_; + + if (move_time > 0 && (this->position - 0.5f) * dir_factor < 0.5f) { + auto move_step = dir_factor * travel_time / move_time; + this->position += move_step; + this->position = clamp(this->position, 0.0f, 1.0f); + } + + if (this->is_at_target_position_()) { + this->last_recompute_time_ = now; + this->target_position_ = TARGET_NONE; + this->last_publish_time_ = now; + + // If the cover is in extreme positions, run recalibration + if (this->recalibration_time_ > 0 && + (((this->position == COVER_CLOSED && (tilt_time == 0 || this->tilt == COVER_CLOSED)) || + (this->position == COVER_OPEN && (tilt_time == 0 || this->tilt == COVER_OPEN))))) { + this->fsm_state_ = STATE_CALIBRATING; + this->publish_state(false); + } else { + this->fsm_state_ = STATE_STOPPING; + } + } + + if (now - this->last_publish_time_ > 1000) { + this->publish_state(false); + this->last_publish_time_ = now; + } + } +} + +float TimeBasedTiltCover::get_setup_priority() const { return setup_priority::DATA; } +CoverTraits TimeBasedTiltCover::get_traits() { + auto traits = CoverTraits(); + traits.set_supports_position(true); + traits.set_supports_tilt(this->tilt_close_duration_ != 0 && this->tilt_open_duration_ != 0); + traits.set_supports_toggle(true); + traits.set_supports_stop(true); + traits.set_is_assumed_state(this->assumed_state_); + return traits; +} +void TimeBasedTiltCover::control(const CoverCall &call) { + if (call.get_stop()) { + this->target_position_ = TARGET_NONE; + this->target_tilt_ = TARGET_NONE; + this->fsm_state_ = STATE_STOPPING; + return; + } + if (call.get_position().has_value() && call.get_tilt().has_value()) { + auto pos = *call.get_position(); + auto til = *call.get_tilt(); + + if (this->round_position(pos) == this->round_position(this->position)) + pos = TARGET_NONE; + if (this->round_position(til) == this->round_position(this->tilt)) + til = TARGET_NONE; + + this->target_position_ = pos; + this->target_tilt_ = til; + + if (this->fsm_state_ == STATE_MOVING) { + auto direction = COVER_OPERATION_IDLE; + if (this->target_position_ != TARGET_NONE && this->target_position_ != this->position) { + direction = this->compute_direction(this->target_position_, this->position); + } else if (this->target_tilt_ != TARGET_NONE && this->target_tilt_ != this->tilt) { + direction = this->compute_direction(this->target_tilt_, this->tilt); + } + + if (direction != this->current_operation) { + this->fsm_state_ = STATE_STOPPING; + } + } + } else if (call.get_position().has_value()) { + auto pos = *call.get_position(); + + if (pos == COVER_CLOSED && this->position == COVER_CLOSED && this->tilt != COVER_CLOSED) { + pos = TARGET_NONE; + this->target_tilt_ = COVER_CLOSED; + } else if (pos == COVER_OPEN && this->position == COVER_OPEN && this->tilt != COVER_OPEN) { + pos = TARGET_NONE; + this->target_tilt_ = COVER_OPEN; + } else if (this->round_position(pos) == this->round_position(this->position)) { + pos = TARGET_NONE; + } + + this->target_position_ = pos; + + if (this->fsm_state_ == STATE_MOVING) { + auto direction = COVER_OPERATION_IDLE; + if (this->target_position_ != TARGET_NONE && this->target_position_ != this->position) { + direction = this->compute_direction(this->target_position_, this->position); + this->target_tilt_ = TARGET_NONE; // unset previous target tilt + } else if (this->target_tilt_ != TARGET_NONE && this->target_tilt_ != this->tilt) { + direction = this->compute_direction(this->target_tilt_, this->tilt); + } + + if (direction != this->current_operation) { + this->fsm_state_ = STATE_STOPPING; + } + } + } else if (call.get_tilt().has_value()) { + auto til = *call.get_tilt(); + if (this->round_position(til) == this->round_position(this->tilt)) { + til = TARGET_NONE; + } + + this->target_tilt_ = til; + + if (this->fsm_state_ == STATE_MOVING) { + auto direction = COVER_OPERATION_IDLE; + if (this->target_tilt_ != TARGET_NONE && this->target_tilt_ != this->tilt) { + direction = this->compute_direction(this->target_tilt_, this->tilt); + this->target_position_ = TARGET_NONE; + } + + if (direction != this->current_operation) { + this->fsm_state_ = STATE_STOPPING; + } + } + } + + if (call.get_toggle().has_value()) { + if (this->current_operation != COVER_OPERATION_IDLE) { + this->fsm_state_ = STATE_STOPPING; + this->target_position_ = TARGET_NONE; + this->target_tilt_ = TARGET_NONE; + } else { + if (this->position == COVER_CLOSED && this->tilt == COVER_CLOSED) { + this->target_position_ = COVER_OPEN; + } else if (this->position == COVER_OPEN && this->tilt == COVER_OPEN) { + this->target_position_ = COVER_CLOSED; + } else if (this->last_operation_ == COVER_OPERATION_CLOSING) { + if (this->position != COVER_OPEN) { + this->target_position_ = COVER_OPEN; + } else { + this->target_tilt_ = COVER_OPEN; + } + } else { + if (this->position != COVER_CLOSED) { + this->target_position_ = COVER_CLOSED; + } else { + this->target_tilt_ = COVER_CLOSED; + } + } + } + } +} + +} // namespace time_based_tilt +} // namespace esphome diff --git a/esphome/components/time_based_tilt/time_based_tilt_cover.h b/esphome/components/time_based_tilt/time_based_tilt_cover.h new file mode 100644 index 0000000000..9384c26b7e --- /dev/null +++ b/esphome/components/time_based_tilt/time_based_tilt_cover.h @@ -0,0 +1,72 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/automation.h" +#include "esphome/components/cover/cover.h" + +namespace esphome { +namespace time_based_tilt { + +class TimeBasedTiltCover : public cover::Cover, public Component { + public: + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override; + + Trigger<> *get_open_trigger() const { return this->open_trigger_; } + Trigger<> *get_close_trigger() const { return this->close_trigger_; } + Trigger<> *get_stop_trigger() const { return this->stop_trigger_; } + void set_open_duration(uint32_t open_duration) { this->open_duration_ = open_duration; } + void set_close_duration(uint32_t close_duration) { this->close_duration_ = close_duration; } + void set_tilt_open_duration(uint32_t tilt_open_duration) { this->tilt_open_duration_ = tilt_open_duration; } + void set_tilt_close_duration(uint32_t tilt_close_duration) { this->tilt_close_duration_ = tilt_close_duration; } + void set_interlock_wait_time(uint32_t interlock_wait_time) { this->interlock_wait_time_ = interlock_wait_time; } + void set_recalibration_time(uint32_t recalibration_time) { this->recalibration_time_ = recalibration_time; } + void set_inertia_open_time(uint32_t inertia_time) { this->inertia_open_time_ = inertia_time; } + void set_inertia_close_time(uint32_t inertia_time) { this->inertia_close_time_ = inertia_time; } + cover::CoverOperation compute_direction(float target, float current) { + return target < current ? cover::COVER_OPERATION_CLOSING : cover::COVER_OPERATION_OPENING; + }; + float round_position(float pos) { return round(100 * pos) / 100; }; + cover::CoverTraits get_traits() override; + void set_assumed_state(bool value) { this->assumed_state_ = value; } + + protected: + void control(const cover::CoverCall &call) override; + bool is_at_target_position_() const; + bool is_at_target_tilt_() const; + + Trigger<> *open_trigger_{new Trigger<>()}; + Trigger<> *close_trigger_{new Trigger<>()}; + Trigger<> *stop_trigger_{new Trigger<>()}; + + uint32_t open_duration_; + uint32_t close_duration_; + + uint32_t tilt_close_duration_; + uint32_t tilt_open_duration_; + + uint32_t interlock_wait_time_; + uint32_t recalibration_time_; + uint32_t inertia_open_time_; + uint32_t inertia_close_time_; + + const static float TARGET_NONE; + enum State : uint8_t { STATE_IDLE, STATE_MOVING, STATE_STOPPING, STATE_CALIBRATING }; + + uint32_t last_recompute_time_{0}; + uint32_t last_publish_time_{0}; + float target_position_{TARGET_NONE}; + float target_tilt_{TARGET_NONE}; + float inertia_{0.0f}; + bool has_built_in_endstop_{false}; + bool assumed_state_{false}; + cover::CoverOperation last_operation_{cover::COVER_OPERATION_OPENING}; + State fsm_state_{STATE_IDLE}; + cover::CoverOperation interlocked_direction_{cover::COVER_OPERATION_IDLE}; + uint32_t interlocked_time_{0}; +}; + +} // namespace time_based_tilt +} // namespace esphome diff --git a/tests/test3.1.yaml b/tests/test3.1.yaml index 2bddd6f4d7..15e7525aab 100644 --- a/tests/test3.1.yaml +++ b/tests/test3.1.yaml @@ -418,6 +418,7 @@ binary_sensor: - cover.toggle: time_based_cover - cover.toggle: endstop_cover - cover.toggle: current_based_cover + - cover.toggle: time_based_tilt_cover globals: - id: my_global_string @@ -610,6 +611,23 @@ cover: close_action: - switch.turn_on: gpio_switch2 close_duration: 4.5min + - platform: time_based_tilt + name: Time Based Cover with Tilt + id: time_based_tilt_cover + stop_action: + - switch.turn_on: gpio_switch1 + open_action: + - switch.turn_on: gpio_switch1 + open_duration: 5min + close_action: + - switch.turn_on: gpio_switch2 + close_duration: 4.5min + inertia_open_time: 300 ms + tilt_open_duration: 930 ms + inertia_close_time: 250 ms + tilt_close_duration: 900 ms + interlock_wait_time: 500ms + recalibration_time: 2500ms - platform: current_based name: Current Based Cover id: current_based_cover