diff --git a/lib/openhab/dsl.rb b/lib/openhab/dsl.rb index 01b513374..a62e3c58b 100644 --- a/lib/openhab/dsl.rb +++ b/lib/openhab/dsl.rb @@ -666,7 +666,7 @@ def ensure_states!(active: true) end # - # Global method that takes a block and for the duration of the block + # Global method that takes a block and for the duration of the block, # all commands sent will check if the item is in the command's state # before sending the command. This also applies to updates. # @@ -717,6 +717,68 @@ def ensure_states ensure_states!(active: old) end + # + # Permanently set the _default_ `only_when_ensured` to true for {Items::TimedCommand TimedCommand}s + # for the current thread. + # + # When `only_when_ensured` is true, the timer in a timed command will only be started if the item's + # current state is not the same as the command. + # + # The option `only_when_ensured` can still be overridden by passing the `only_when_ensured` argument to + # a timed command. + # + # @note This method is only intended for use at the top level of rule + # scripts. If it's used within library methods, or hap-hazardly within + # rules, things can get very confusing because the prior state won't be + # properly restored. + # + # @param [Boolean] default Whether to set the default for `only_when_ensured` option to true. + # @return [Boolean] The previous ensure_timed_commands setting. + # + # @example Make `only_when_ensured`: true the default for the rest of the script + # # The default is `only_when_ensured`: false, so the timer will start regardless of the item's current state + # Item.ensure.command 50, for: 5.minutes + # + # ensure_timed_commands! + # + # # From now, the default is `only_when_ensured`: true, + # # so the timer will only start if the item's current state is different + # Item.command 50, for: 5.minutes + # + # # It can still be overridden by passing `only_when_ensured: false` + # Item.command 50, for: 5.minutes, only_when_ensured: false + # + # @see Items::TimedCommand TimedCommand + # + def ensure_timed_commands!(default: true) + old = Thread.current[:openhab_ensure_timed_commands] + Thread.current[:openhab_ensure_timed_commands] = default + old + end + + # + # Global method that takes a block and for the duration of the block, + # all timed commands will default to `only_when_ensured`: true + # + # @example + # ensure_timed_commands do + # # `only_when_ensured` defaults to true for all timed commands inside the block + # Item.on for: 5.minutes + # + # # It does not affect non timed-commands + # # so a call to {ensure} still needs to be done when required + # Item2.ensure.on + # end + # + # @see Items::TimedCommand TimedCommand + # + def ensure_timed_commands + old = ensure_timed_commands! + yield + ensure + ensure_timed_commands!(default: old) + end + # # Sets a thread local variable to set the default persistence service # for method calls inside the block @@ -1088,6 +1150,8 @@ def try_parse_time_like(string) # Provide access to the script context / variables # see OpenHAB::DSL::Rules::AutomationRule#execute! # + # + # # @!visibility private ruby2_keywords def method_missing(method, *args) return super unless args.empty? && !block_given? diff --git a/lib/openhab/dsl/items/timed_command.rb b/lib/openhab/dsl/items/timed_command.rb index 202a6783c..e39ed3070 100644 --- a/lib/openhab/dsl/items/timed_command.rb +++ b/lib/openhab/dsl/items/timed_command.rb @@ -89,12 +89,17 @@ class << self # @param [Command] command to send to object # @param [Duration] for duration for item to be in command state # @param [Command] on_expire Command to send when duration expires - # @param [true, false] only_when_ensured if true, only start the timed command if the command was ensured + # @param [true, false, nil] only_when_ensured + # - When `true`, only start the timed command if the command was ensured. + # - When `false`, the timed command will be started regardless of the prior state of the item, even when + # {OpenHAB::DSL.ensure_timed_commands ensure_timed_commands} is in effect. + # - When `nil`, the timed command will be started unless + # {OpenHAB::DSL.ensure_timed_commands ensure_timed_commands} is in effect. # @yield If a block is provided, `on_expire` is ignored and the block # is expected to set the item to the desired state or carry out some # other action. # @yieldparam [TimedCommandDetails] timed_command - # @return [self] + # @return [self, nil] self if the timer was started or extended, nil if the timer was not started. # # @example # Switch.command(ON, for: 5.minutes) @@ -116,7 +121,10 @@ class << self # end # end # - def command(command, for: nil, on_expire: nil, only_when_ensured: false, &block) + # @see DSL.ensure_timed_commands + # @see DSL.ensure_timed_commands! + # + def command(command, for: nil, on_expire: nil, only_when_ensured: nil, &block) duration = binding.local_variable_get(:for) return super(command) unless duration @@ -124,6 +132,7 @@ def command(command, for: nil, on_expire: nil, only_when_ensured: false, &block) create_ensured_timed_command = proc do on_expire ||= default_on_expire(command) + only_when_ensured ||= Thread.current[:openhab_ensure_timed_commands] if only_when_ensured DSL.ensure_states do create_timed_command(command, duration: duration, on_expire: on_expire) if super(command) @@ -134,7 +143,7 @@ def command(command, for: nil, on_expire: nil, only_when_ensured: false, &block) end end - TimedCommand.timed_commands.compute(self) do |_key, timed_command_details| + timed_command = TimedCommand.timed_commands.compute(self) do |_key, timed_command_details| if timed_command_details.nil? # no prior timed command create_ensured_timed_command.call @@ -160,7 +169,7 @@ def command(command, for: nil, on_expire: nil, only_when_ensured: false, &block) end end - self + Core::Items::Proxy.new(self) if timed_command end private diff --git a/lib/openhab/dsl/thread_local.rb b/lib/openhab/dsl/thread_local.rb index 24bf1037d..77b6ad108 100644 --- a/lib/openhab/dsl/thread_local.rb +++ b/lib/openhab/dsl/thread_local.rb @@ -13,6 +13,7 @@ module ThreadLocal KNOWN_KEYS = %i[ openhab_context openhab_ensure_states + openhab_ensure_timed_commands openhab_holiday_file openhab_persistence_service openhab_providers diff --git a/spec/openhab/dsl/items/timed_command_spec.rb b/spec/openhab/dsl/items/timed_command_spec.rb index 8b143e7cd..83988b9d8 100644 --- a/spec/openhab/dsl/items/timed_command_spec.rb +++ b/spec/openhab/dsl/items/timed_command_spec.rb @@ -31,64 +31,134 @@ expect(item.state).to eq 0 end - it "can activate only when ensured" do - commanded = false - received_command(item) { commanded = true } - - if commanded # This won't execute because it's only for self documentation - # First check our assumptions of the behavior without `only_when_ensured` - # Possibly unnecessary because such behavior is already tested in other specs - # but nice to have here for clarity - # - # ******** - # first without ensure - item.update 7 - item.command(7, for: 1.second, on_expire: 0) - expect(commanded).to be true + it "returns `self` (wrapped in a proxy)" do + expect(item.command(7, for: 1.seconds)).to be item + end + context "with `only_when_ensured` option" do + it "can activate only when ensured" do commanded = false - time_travel_and_execute_timers(2.seconds) - expect(commanded).to be true - expect(item.state).to eq 0 + received_command(item) { commanded = true } + + if commanded # This won't execute because it's only for self documentation + # First check our assumptions of the behavior without `only_when_ensured` + # Possibly unnecessary because such behavior is already tested in other specs + # but nice to have here for clarity + # + # ******** + # first without ensure + item.update 7 + item.command(7, for: 1.second, on_expire: 0) + expect(commanded).to be true + + commanded = false + time_travel_and_execute_timers(2.seconds) + expect(commanded).to be true + expect(item.state).to eq 0 + + # ******** + # now with ensure (but still without `only_when_ensured`) + item.update(7) + commanded = false + item.ensure.command(7, for: 1.second, on_expire: 0) + expect(commanded).to be false + + commanded = false + + # the timed command still executes even though the command was ensured + time_travel_and_execute_timers(2.seconds) + expect(commanded).to be true + expect(item.state).to eq 0 + end # ******** - # now with ensure (but still without `only_when_ensured`) + # now try it with `only_when_ensured` item.update(7) commanded = false - item.ensure.command(7, for: 1.second, on_expire: 0) + item.command(7, for: 1.second, on_expire: 0, only_when_ensured: true) + expect(commanded).to be false + + time_travel_and_execute_timers(2.seconds) expect(commanded).to be false + # The difference is here: the timer didn't even start, so the state didn't change to `on_expire` state + expect(item.state).to eq 7 + # ******** + # calling ensure explicitly should still work + item.update(7) # not necessary but for clarity commanded = false + item.ensure.command(7, for: 1.second, on_expire: 0, only_when_ensured: true) + expect(commanded).to be false - # the timed command still executes even though the command was ensured time_travel_and_execute_timers(2.seconds) - expect(commanded).to be true - expect(item.state).to eq 0 + expect(commanded).to be false + # The difference is here: the timer didn't even start, so the state didn't change to `on_expire` state + expect(item.state).to eq 7 end - # ******** - # now try it with `only_when_ensured` - item.update(7) - commanded = false - item.command(7, for: 1.second, on_expire: 0, only_when_ensured: true) - expect(commanded).to be false + it "returns self when the timer was started" do + item.update(0) + result = item.command(7, for: 1.second, only_when_ensured: true) + expect(result).to be item + end - time_travel_and_execute_timers(2.seconds) - expect(commanded).to be false - # The difference is here: the timer didn't even start, so the state didn't change to `on_expire` state - expect(item.state).to eq 7 + it "returns nil when the timer was not started" do + item.update(0) + result = item.command(0, for: 1.second, only_when_ensured: true) + expect(result).to be_nil + end + end - # ******** - # calling ensure explicitly should still work - item.update(7) # not necessary but for clarity - commanded = false - item.ensure.command(7, for: 1.second, on_expire: 0, only_when_ensured: true) - expect(commanded).to be false + describe "#ensure_timed_commands!" do + around do |example| + ensure_timed_commands! + example.run + ensure + ensure_timed_commands!(default: false) + end - time_travel_and_execute_timers(2.seconds) - expect(commanded).to be false - # The difference is here: the timer didn't even start, so the state didn't change to `on_expire` state - expect(item.state).to eq 7 + it "makes `only_when_ensured` defaults to true for all timed commands" do + commanded = false + received_command(item) { commanded = true } + + item.update(7) + # only_when_ensured is not specified in this call, but it should now defaults to true + item.command(7, for: 1.second, on_expire: 0) + expect(commanded).to be false + + time_travel_and_execute_timers(2.seconds) + expect(commanded).to be false + # check that the timer didn't even start, so the state didn't change to `on_expire` state + expect(item.state).to eq 7 + end + end + + describe "#ensure_timed_commands" do + it "only takes effect inside the block" do + commanded = false + received_command(item) { commanded = true } + + ensure_timed_commands do + item.update(7) + # only_when_ensured is not specified in this call, but it should now defaults to true + item.command(7, for: 1.second, on_expire: 0) + expect(commanded).to be false + + time_travel_and_execute_timers(2.seconds) + expect(commanded).to be false + # check that the timer didn't even start, so the state didn't change to `on_expire` state + expect(item.state).to eq 7 + end + + # After the block, it shoult not default to true anymore + item.update(7) + commanded = false + item.command(7, for: 1.second, on_expire: 0) + expect(commanded).to be true + + time_travel_and_execute_timers(2.seconds) + expect(item.state).to eq 0 + end end context "with SwitchItem" do