From adc5e22e06cd347ba575724487a7ccd86bfd13bf Mon Sep 17 00:00:00 2001 From: Jimmy Tanagra Date: Sat, 2 Sep 2023 10:58:58 +1000 Subject: [PATCH] Support providing and accessing context in rules Signed-off-by: Jimmy Tanagra --- USAGE.md | 23 ++++++ lib/openhab/core/rules/rule.rb | 21 +++++- lib/openhab/dsl.rb | 19 +++++ lib/openhab/dsl/rules/automation_rule.rb | 32 +++++++- lib/openhab/dsl/thread_local.rb | 1 + spec/openhab/dsl/rules/builder_spec.rb | 94 ++++++++++++++++++++++++ spec/openhab/dsl_spec.rb | 20 +++++ 7 files changed, 202 insertions(+), 8 deletions(-) diff --git a/USAGE.md b/USAGE.md index 6734013efc..0eca09b1ac 100644 --- a/USAGE.md +++ b/USAGE.md @@ -1212,6 +1212,29 @@ rules[rule_uid].enable rules[rule_uid].disable ``` +#### Passing Values to Rules + +A rule/script may be given additional context/data by the caller. This additional data is available +within the rule by referring to the names of the context variable. This is applicable to both +UI rules and file-based rules. + +Within the script/rule body (either UI or file rule) + +```ruby +script id: "check_temp" do + if CPU_Temperature.state > maxTemperature + logger.warn "The CPU is overheating!" + end +end +``` + +The above script can be executed, passing it the `maxTemperature` argument from any supported +scripting language, e.g.: + +```ruby +rules["check_temp"].trigger(maxTemperature: 80 | "°C") +``` + ### Gems [Bundler](https://bundler.io/) is integrated, enabling any [Ruby gem](https://rubygems.org/) compatible with JRuby to be used within rules. This permits easy access to the vast ecosystem of libraries within the Ruby community. diff --git a/lib/openhab/core/rules/rule.rb b/lib/openhab/core/rules/rule.rb index 727749efe6..e14368a7ce 100644 --- a/lib/openhab/core/rules/rule.rb +++ b/lib/openhab/core/rules/rule.rb @@ -163,11 +163,24 @@ def to_s # Manually trigger the rule # # @param [Object, nil] event The event to pass to the rule's execution blocks. - # @return [void] - # - def trigger(event = nil) - Rules.manager.run_now(uid, false, { "event" => event }) + # @param [Boolean] consider_conditions Whether to check the conditions of the called rules. + # @param [kwargs] context The context to pass to the conditions and the actions of the rule. + # @return [Hash] A copy of the rule context, including possible return values. + # + def trigger(event = nil, consider_conditions: false, **context) + begin + event ||= org.openhab.core.automation.events.AutomationEventFactory + .createExecutionEvent(uid, nil, "manual") + rescue NameError + # @deprecated OH3.4 doesn't have AutomationEventFactory + end + context.transform_keys!(&:to_s) + # Unwrap any proxies and pass raw objects (items, things) + context.transform_values! { |value| value.is_a?(Delegator) ? value.__getobj__ : value } + context["event"] = event if event # @deprecated OH3.4 - remove if guard. In OH4 `event` will never be nil + Rules.manager.run_now(uid, consider_conditions, context) end + alias_method :run, :trigger end end end diff --git a/lib/openhab/dsl.rb b/lib/openhab/dsl.rb index 4eb50af3f0..cd7d3dddb2 100644 --- a/lib/openhab/dsl.rb +++ b/lib/openhab/dsl.rb @@ -2,6 +2,7 @@ require "java" require "method_source" +require "ruby2_keywords" require "bundler/inline" @@ -970,6 +971,24 @@ def try_parse_time_like(string) raise exception end + + # + # 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? + return super unless (context = Thread.current[:openhab_context]) && context.key?(method) + + logger.trace("DSL#method_missing found context variable: '#{method}'") + context[method] + end + + # @!visibility private + def respond_to_missing?(method, include_private = false) + Thread.current[:openhab_context]&.key?(method) || super + end end end diff --git a/lib/openhab/dsl/rules/automation_rule.rb b/lib/openhab/dsl/rules/automation_rule.rb index 29971f32bf..ee81cbc328 100644 --- a/lib/openhab/dsl/rules/automation_rule.rb +++ b/lib/openhab/dsl/rules/automation_rule.rb @@ -74,6 +74,10 @@ def on_removal(listener) # This method gets called in rspec's SuspendRules as well def execute!(mod, inputs) + # Store the context in a thread variable. It is accessed through DSL#method_missing + # which is triggered when the context variable is referenced inside the run block. + # It is added to @thread_locals so it is also available in #process_task below. + @thread_locals[:openhab_context] = extract_context(inputs) ThreadLocal.thread_local(**@thread_locals) do if logger.trace? logger.trace("Execute called with mod (#{mod&.to_string}) and inputs (#{inputs.inspect})") @@ -88,6 +92,8 @@ def execute!(mod, inputs) @run_context.send(:logger).log_exception(e) end + ensure + @thread_locals.delete(:openhab_context) end def cleanup @@ -159,6 +165,24 @@ def extract_event(inputs) struct_class.new(**inputs) end + # + # Converts inputs into context hash + # @return [Hash] Context hash. + # + def extract_context(inputs) + return unless inputs + + inputs.reject { |key, _| key.include?(".") } + .to_h do |key, value| + [key.to_sym, + if value.is_a?(Item) && !value.is_a?(Core::Items::Proxy) + Core::Items::Proxy.new(value) + else + value + end] + end + end + # # Get the trigger_id for the trigger that caused the rule creation # @@ -232,7 +256,7 @@ def process_task(event, task) ThreadLocal.thread_local(**@thread_locals) do case task when BuilderDSL::Run then process_run_task(event, task) - when BuilderDSL::Script then process_script_task(task) + when BuilderDSL::Script then process_script_task(event, task) when BuilderDSL::Trigger then process_trigger_task(event, task) when BuilderDSL::Otherwise then process_otherwise_task(event, task) end @@ -296,9 +320,9 @@ def process_run_task(event, task) # # @param [Script] task to execute # - def process_script_task(task) - logger.trace { "Executing script '#{name}' run block" } - @run_context.instance_exec(&task.block) + def process_script_task(event, task) + logger.trace { "Executing script '#{name}' run block with event(#{event})" } + @run_context.instance_exec(event, &task.block) end # diff --git a/lib/openhab/dsl/thread_local.rb b/lib/openhab/dsl/thread_local.rb index 4ec1889d50..24bf1037d8 100644 --- a/lib/openhab/dsl/thread_local.rb +++ b/lib/openhab/dsl/thread_local.rb @@ -11,6 +11,7 @@ module ThreadLocal # Keys to persist KNOWN_KEYS = %i[ + openhab_context openhab_ensure_states openhab_holiday_file openhab_persistence_service diff --git a/spec/openhab/dsl/rules/builder_spec.rb b/spec/openhab/dsl/rules/builder_spec.rb index a6091de61f..cab90c8285 100644 --- a/spec/openhab/dsl/rules/builder_spec.rb +++ b/spec/openhab/dsl/rules/builder_spec.rb @@ -1487,6 +1487,17 @@ def self.test_event(trigger) Switch1.on expect(item).to be Switch1 end + + it "has an implicit `event`", caller: caller do + items.build { switch_item "Switch1" } + item = nil + rule do + send(trigger, Switch1) + run { item = event.item } + end + Switch1.on + expect(item).to be Switch1 + end end test_event(:changed) @@ -1502,6 +1513,57 @@ def self.test_event(trigger) end expect(ran).to be 3 end + + context "with context variables" do + it "works" do + items.build { switch_item TestSwitch } + received_context = nil + rule do + received_command TestSwitch + run do + # `command` was injected by ItemCommandTriggerHandler + received_context = command + end + end + + TestSwitch.on + expect(received_context).to be ON + end + + it "local variables hide context variables" do + items.build { switch_item TestSwitch } + local_var = nil + command = :foo + rule do + received_command TestSwitch + run do + # `command` was injected by ItemCommandTriggerHandler + local_var = command + end + end + + TestSwitch.on + expect(local_var).to be :foo + end + + it "methods hide context variables" do + items.build { switch_item TestSwitch } + method_result = nil + def command + :foo + end + rule do + received_command TestSwitch + run do + # `command` was injected by ItemCommandTriggerHandler + method_result = command + end + end + + TestSwitch.on + expect(method_result).to be :foo + end + end end describe "#delay" do @@ -1517,6 +1579,25 @@ def self.test_event(trigger) time_travel_and_execute_timers(10.seconds) expect(executed).to be 2 end + + it "has access to its context in subsequent run blocks" do + items.build { switch_item TestSwitch } + received_context = nil + rule do + received_command TestSwitch + run {} # rubocop:disable Lint/EmptyBlock + delay 0.1.seconds + run do + # `command` was injected by ItemCommandTriggerHandler + received_context = command + end + end + + TestSwitch.on + expect(received_context).to be_nil + time_travel_and_execute_timers(0.2.seconds) + expect(received_context).to be ON + end end describe "triggered" do @@ -1706,6 +1787,19 @@ def self.test_combo(only_if_value, not_if_value, result) expect(this).to be self end + it "has access to its context variables" do + items.build { switch_item Switch1 } + received_context = nil + rule do + received_command Switch1 + # `command` was injected by ItemCommandTriggerHandler + only_if { received_context = command } + run {} # rubocop:disable Lint/EmptyBlock + end + Switch1.on + expect(received_context).to be ON + end + describe "#between" do # rubocop:disable RSpec/EmptyExampleGroup def self.test_it(range, expected) it "works with #{range.inspect} (#{range.begin.class})", caller: caller do diff --git a/spec/openhab/dsl_spec.rb b/spec/openhab/dsl_spec.rb index c301166bc8..9771b4e80b 100644 --- a/spec/openhab/dsl_spec.rb +++ b/spec/openhab/dsl_spec.rb @@ -82,6 +82,16 @@ rules["testscript"].trigger expect(triggered).to be true end + + it "can access its context" do + received_context = nil + script id: "testscript" do + received_context = foo + end + + rules["testscript"].trigger(nil, foo: "bar") + expect(received_context).to eql "bar" + end end describe "#scenes" do @@ -94,6 +104,16 @@ rules.scenes["testscene"].trigger expect(triggered).to be true end + + it "can access its context" do + received_context = nil + scene id: "testscene" do + received_context = foo + end + + rules["testscene"].trigger(nil, foo: "bar") + expect(received_context).to eql "bar" + end end describe "#store_states" do