diff --git a/esphome/components/bang_bang/bang_bang_climate.cpp b/esphome/components/bang_bang/bang_bang_climate.cpp index cf527988fe..45d5174390 100644 --- a/esphome/components/bang_bang/bang_bang_climate.cpp +++ b/esphome/components/bang_bang/bang_bang_climate.cpp @@ -108,7 +108,7 @@ void BangBangClimate::switch_to_action_(climate::ClimateAction action) { } if (this->prev_trigger_ != nullptr) { - this->prev_trigger_->stop(); + this->prev_trigger_->stop_action(); this->prev_trigger_ = nullptr; } Trigger<> *trig; diff --git a/esphome/components/endstop/endstop_cover.cpp b/esphome/components/endstop/endstop_cover.cpp index 1c239226c1..8e20cb6a29 100644 --- a/esphome/components/endstop/endstop_cover.cpp +++ b/esphome/components/endstop/endstop_cover.cpp @@ -94,7 +94,7 @@ void EndstopCover::dump_config() { float EndstopCover::get_setup_priority() const { return setup_priority::DATA; } void EndstopCover::stop_prev_trigger_() { if (this->prev_command_trigger_ != nullptr) { - this->prev_command_trigger_->stop(); + this->prev_command_trigger_->stop_action(); this->prev_command_trigger_ = nullptr; } } diff --git a/esphome/components/light/base_light_effects.h b/esphome/components/light/base_light_effects.h index dcef60397d..d6d930e9d4 100644 --- a/esphome/components/light/base_light_effects.h +++ b/esphome/components/light/base_light_effects.h @@ -67,9 +67,9 @@ class LambdaLightEffect : public LightEffect { class AutomationLightEffect : public LightEffect { public: AutomationLightEffect(const std::string &name) : LightEffect(name) {} - void stop() override { this->trig_->stop(); } + void stop() override { this->trig_->stop_action(); } void apply() override { - if (!this->trig_->is_running()) { + if (!this->trig_->is_action_running()) { this->trig_->trigger(); } } diff --git a/esphome/components/script/__init__.py b/esphome/components/script/__init__.py index 5075bb1f56..eb337d7681 100644 --- a/esphome/components/script/__init__.py +++ b/esphome/components/script/__init__.py @@ -2,7 +2,7 @@ import esphome.codegen as cg import esphome.config_validation as cv from esphome import automation from esphome.automation import maybe_simple_id -from esphome.const import CONF_ID +from esphome.const import CONF_ID, CONF_MODE script_ns = cg.esphome_ns.namespace('script') Script = script_ns.class_('Script', automation.Trigger.template()) @@ -10,10 +10,47 @@ ScriptExecuteAction = script_ns.class_('ScriptExecuteAction', automation.Action) ScriptStopAction = script_ns.class_('ScriptStopAction', automation.Action) ScriptWaitAction = script_ns.class_('ScriptWaitAction', automation.Action, cg.Component) IsRunningCondition = script_ns.class_('IsRunningCondition', automation.Condition) +SingleScript = script_ns.class_('SingleScript', Script) +RestartScript = script_ns.class_('RestartScript', Script) +QueueingScript = script_ns.class_('QueueingScript', Script, cg.Component) +ParallelScript = script_ns.class_('ParallelScript', Script) + +CONF_SINGLE = 'single' +CONF_RESTART = 'restart' +CONF_QUEUE = 'queue' +CONF_PARALLEL = 'parallel' +CONF_MAX_RUNS = 'max_runs' + +SCRIPT_MODES = { + CONF_SINGLE: SingleScript, + CONF_RESTART: RestartScript, + CONF_QUEUE: QueueingScript, + CONF_PARALLEL: ParallelScript, +} + + +def check_max_runs(value): + if CONF_MAX_RUNS not in value: + return value + if value[CONF_MODE] not in [CONF_QUEUE, CONF_PARALLEL]: + raise cv.Invalid("The option 'max_runs' is only valid in 'queue' and 'parallel' mode.", + path=[CONF_MAX_RUNS]) + return value + + +def assign_declare_id(value): + value = value.copy() + value[CONF_ID] = cv.declare_id(SCRIPT_MODES[value[CONF_MODE]])(value[CONF_ID]) + return value + CONFIG_SCHEMA = automation.validate_automation({ - cv.Required(CONF_ID): cv.declare_id(Script), -}) + # Don't declare id as cv.declare_id yet, because the ID type + # dpeends on the mode. Will be checked later with assign_declare_id + cv.Required(CONF_ID): cv.string_strict, + cv.Optional(CONF_MODE, default=CONF_SINGLE): cv.one_of(*SCRIPT_MODES, lower=True), + cv.Optional(CONF_MAX_RUNS): cv.positive_int, +}, extra_validators=cv.All(check_max_runs, assign_declare_id)) def to_code(config): @@ -21,6 +58,15 @@ def to_code(config): triggers = [] for conf in config: trigger = cg.new_Pvariable(conf[CONF_ID]) + # Add a human-readable name to the script + cg.add(trigger.set_name(conf[CONF_ID].id)) + + if CONF_MAX_RUNS in conf: + cg.add(trigger.set_max_runs(conf[CONF_MAX_RUNS])) + + if conf[CONF_MODE] == CONF_QUEUE: + yield cg.register_component(trigger, conf) + triggers.append((trigger, conf)) for trigger, conf in triggers: diff --git a/esphome/components/script/script.cpp b/esphome/components/script/script.cpp new file mode 100644 index 0000000000..f4441b7dcd --- /dev/null +++ b/esphome/components/script/script.cpp @@ -0,0 +1,67 @@ +#include "script.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace script { + +static const char *TAG = "script"; + +void SingleScript::execute() { + if (this->is_action_running()) { + ESP_LOGW(TAG, "Script '%s' is already running! (mode: single)", this->name_.c_str()); + return; + } + + this->trigger(); +} + +void RestartScript::execute() { + if (this->is_action_running()) { + ESP_LOGD(TAG, "Script '%s' restarting (mode: restart)", this->name_.c_str()); + this->stop_action(); + } + + this->trigger(); +} + +void QueueingScript::execute() { + if (this->is_action_running()) { + // num_runs_ is the number of *queued* instances, so total number of instances is + // num_runs_ + 1 + if (this->max_runs_ != 0 && this->num_runs_ + 1 >= this->max_runs_) { + ESP_LOGW(TAG, "Script '%s' maximum number of queued runs exceeded!", this->name_.c_str()); + return; + } + + ESP_LOGD(TAG, "Script '%s' queueing new instance (mode: queue)", this->name_.c_str()); + this->num_runs_++; + return; + } + + this->trigger(); + // Check if the trigger was immediate and we can continue right away. + this->loop(); +} + +void QueueingScript::stop() { + this->num_runs_ = 0; + Script::stop(); +} + +void QueueingScript::loop() { + if (this->num_runs_ != 0 && !this->is_action_running()) { + this->num_runs_--; + this->trigger(); + } +} + +void ParallelScript::execute() { + if (this->max_runs_ != 0 && this->automation_parent_->num_running() >= this->max_runs_) { + ESP_LOGW(TAG, "Script '%s' maximum number of parallel runs exceeded!", this->name_.c_str()); + return; + } + this->trigger(); +} + +} // namespace script +} // namespace esphome diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index 8495014f00..64db6b80e7 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -1,29 +1,86 @@ #pragma once #include "esphome/core/automation.h" +#include "esphome/core/component.h" namespace esphome { namespace script { +/// The abstract base class for all script types. class Script : public Trigger<> { public: - void execute() { - bool prev = this->in_stack_; - this->in_stack_ = true; - this->trigger(); - this->in_stack_ = prev; - } - bool script_is_running() { return this->in_stack_ || this->is_running(); } + /** Execute a new instance of this script. + * + * The behavior of this function when a script is already running is defined by the subtypes + */ + virtual void execute() = 0; + /// Check if any instance of this script is currently running. + virtual bool is_running() { return this->is_action_running(); } + /// Stop all instances of this script. + virtual void stop() { this->stop_action(); } + + // Internal function to give scripts readable names. + void set_name(const std::string &name) { name_ = name; } protected: - bool in_stack_{false}; + std::string name_; +}; + +/** A script type for which only a single instance at a time is allowed. + * + * If a new instance is executed while the previous one hasn't finished yet, + * a warning is printed and the new instance is discarded. + */ +class SingleScript : public Script { + public: + void execute() override; +}; + +/** A script type that restarts scripts from the beginning when a new instance is started. + * + * If a new instance is started but another one is already running, the existing + * script is stopped and the new instance starts from the beginning. + */ +class RestartScript : public Script { + public: + void execute() override; +}; + +/** A script type that queues new instances that are created. + * + * Only one instance of the script can be active at a time. + */ +class QueueingScript : public Script, public Component { + public: + void execute() override; + void stop() override; + void loop() override; + void set_max_runs(int max_runs) { max_runs_ = max_runs; } + + protected: + int num_runs_ = 0; + int max_runs_ = 0; +}; + +/** A script type that executes new instances in parallel. + * + * If a new instance is started while previous ones haven't finished yet, + * the new one is exeucted in parallel to the other instances. + */ +class ParallelScript : public Script { + public: + void execute() override; + void set_max_runs(int max_runs) { max_runs_ = max_runs; } + + protected: + int max_runs_ = 0; }; template class ScriptExecuteAction : public Action { public: ScriptExecuteAction(Script *script) : script_(script) {} - void play(Ts... x) override { this->script_->trigger(); } + void play(Ts... x) override { this->script_->execute(); } protected: Script *script_; @@ -43,7 +100,7 @@ template class IsRunningCondition : public Condition { public: explicit IsRunningCondition(Script *parent) : parent_(parent) {} - bool check(Ts... x) override { return this->parent_->script_is_running(); } + bool check(Ts... x) override { return this->parent_->is_running(); } protected: Script *parent_; diff --git a/esphome/components/template/cover/template_cover.cpp b/esphome/components/template/cover/template_cover.cpp index 887f282007..147f76af7d 100644 --- a/esphome/components/template/cover/template_cover.cpp +++ b/esphome/components/template/cover/template_cover.cpp @@ -120,7 +120,7 @@ void TemplateCover::set_has_position(bool has_position) { this->has_position_ = void TemplateCover::set_has_tilt(bool has_tilt) { this->has_tilt_ = has_tilt; } void TemplateCover::stop_prev_trigger_() { if (this->prev_command_trigger_ != nullptr) { - this->prev_command_trigger_->stop(); + this->prev_command_trigger_->stop_action(); this->prev_command_trigger_ = nullptr; } } diff --git a/esphome/components/template/switch/template_switch.cpp b/esphome/components/template/switch/template_switch.cpp index 5868b30996..d9f95e203c 100644 --- a/esphome/components/template/switch/template_switch.cpp +++ b/esphome/components/template/switch/template_switch.cpp @@ -19,7 +19,7 @@ void TemplateSwitch::loop() { } void TemplateSwitch::write_state(bool state) { if (this->prev_trigger_ != nullptr) { - this->prev_trigger_->stop(); + this->prev_trigger_->stop_action(); } if (state) { diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index 5d97156e2f..1ffbd3c169 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -223,7 +223,7 @@ void ThermostatClimate::switch_to_action_(climate::ClimateAction action) { } if (this->prev_action_trigger_ != nullptr) { - this->prev_action_trigger_->stop(); + this->prev_action_trigger_->stop_action(); this->prev_action_trigger_ = nullptr; } Trigger<> *trig = this->idle_action_trigger_; @@ -262,7 +262,7 @@ void ThermostatClimate::switch_to_fan_mode_(climate::ClimateFanMode fan_mode) { return; if (this->prev_fan_mode_trigger_ != nullptr) { - this->prev_fan_mode_trigger_->stop(); + this->prev_fan_mode_trigger_->stop_action(); this->prev_fan_mode_trigger_ = nullptr; } Trigger<> *trig = this->fan_mode_auto_trigger_; @@ -313,7 +313,7 @@ void ThermostatClimate::switch_to_mode_(climate::ClimateMode mode) { return; if (this->prev_mode_trigger_ != nullptr) { - this->prev_mode_trigger_->stop(); + this->prev_mode_trigger_->stop_action(); this->prev_mode_trigger_ = nullptr; } Trigger<> *trig = this->auto_mode_trigger_; @@ -355,7 +355,7 @@ void ThermostatClimate::switch_to_swing_mode_(climate::ClimateSwingMode swing_mo return; if (this->prev_swing_mode_trigger_ != nullptr) { - this->prev_swing_mode_trigger_->stop(); + this->prev_swing_mode_trigger_->stop_action(); this->prev_swing_mode_trigger_ = nullptr; } Trigger<> *trig = this->swing_mode_off_trigger_; diff --git a/esphome/components/time_based/time_based_cover.cpp b/esphome/components/time_based/time_based_cover.cpp index 6d1de144f5..1aa3c2471a 100644 --- a/esphome/components/time_based/time_based_cover.cpp +++ b/esphome/components/time_based/time_based_cover.cpp @@ -78,7 +78,7 @@ void TimeBasedCover::control(const CoverCall &call) { } void TimeBasedCover::stop_prev_trigger_() { if (this->prev_command_trigger_ != nullptr) { - this->prev_command_trigger_->stop(); + this->prev_command_trigger_->stop_action(); this->prev_command_trigger_ = nullptr; } } diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 0649e15f5f..c6ce33dd23 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -812,6 +812,7 @@ def mqtt_qos(value): def requires_component(comp): """Validate that this option can only be specified when the component `comp` is loaded.""" def validator(value): + # pylint: disable=unsupported-membership-test if comp not in CORE.raw_config: raise Invalid(f"This option requires component {comp}") return value @@ -1125,7 +1126,7 @@ def typed_schema(schemas, **kwargs): def validator(value): if not isinstance(value, dict): raise Invalid("Value must be dict") - if CONF_TYPE not in value: + if key not in value: raise Invalid("type not specified!") value = value.copy() key_v = key_validator(value.pop(key)) @@ -1175,6 +1176,7 @@ class OnlyWith(Optional): @property def default(self): + # pylint: disable=unsupported-membership-test if self._component not in CORE.raw_config: return vol.UNDEFINED return self._default diff --git a/esphome/core/automation.h b/esphome/core/automation.h index 02bd8bb299..6d79480f0f 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -50,18 +50,22 @@ template class Automation; template class Trigger { public: + /// Inform the parent automation that the event has triggered. void trigger(Ts... x) { if (this->automation_parent_ == nullptr) return; this->automation_parent_->trigger(x...); } void set_automation_parent(Automation *automation_parent) { this->automation_parent_ = automation_parent; } - void stop() { + + /// Stop any action connected to this trigger. + void stop_action() { if (this->automation_parent_ == nullptr) return; this->automation_parent_->stop(); } - bool is_running() { + /// Returns true if any action connected to this trigger is running. + bool is_action_running() { if (this->automation_parent_ == nullptr) return false; return this->automation_parent_->is_running(); @@ -87,8 +91,18 @@ template class Action { } this->stop_next_(); } + /// Check if this or any of the following actions are currently running. virtual bool is_running() { return this->num_running_ > 0 || this->is_running_next_(); } + /// The total number of actions that are currently running in this plus any of + /// the following actions in the chain. + int num_running_total() { + int total = this->num_running_; + if (this->next_ != nullptr) + total += this->next_->num_running_total(); + return total; + } + protected: friend ActionList; @@ -123,6 +137,8 @@ template class Action { Action *next_ = nullptr; + /// The number of instances of this sequence in the list of actions + /// that is currently being executed. int num_running_{0}; }; @@ -151,11 +167,19 @@ template class ActionList { this->actions_begin_->stop_complex(); } bool empty() const { return this->actions_begin_ == nullptr; } + + /// Check if any action in this action list is currently running. bool is_running() { if (this->actions_begin_ == nullptr) return false; return this->actions_begin_->is_running(); } + /// Return the number of actions in this action list that are currently running. + int num_running() { + if (this->actions_begin_ == nullptr) + return false; + return this->actions_begin_->num_running_total(); + } protected: template void play_tuple_(const std::tuple &tuple, seq) { this->play(std::get(tuple)...); } @@ -177,6 +201,9 @@ template class Automation { bool is_running() { return this->actions_.is_running(); } + /// Return the number of actions in the action part of this automation that are currently running. + int num_running() { return this->actions_.num_running(); } + protected: Trigger *trigger_; ActionList actions_;