Skip to content

Commit

Permalink
Support providing and accessing context in rules (#133)
Browse files Browse the repository at this point in the history
Signed-off-by: Jimmy Tanagra <[email protected]>
  • Loading branch information
jimtng authored Sep 8, 2023
1 parent cd7f6da commit 123eb88
Show file tree
Hide file tree
Showing 7 changed files with 202 additions and 8 deletions.
23 changes: 23 additions & 0 deletions USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1228,6 +1228,29 @@ rules[rule_uid].enable
rules[rule_uid].disable
```

#### Passing Values to Rules <!-- omit from toc -->

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.
Expand Down
21 changes: 17 additions & 4 deletions lib/openhab/core/rules/rule.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions lib/openhab/dsl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

require "java"
require "method_source"
require "ruby2_keywords"

require "bundler/inline"

Expand Down Expand Up @@ -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

Expand Down
32 changes: 28 additions & 4 deletions lib/openhab/dsl/rules/automation_rule.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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})")
Expand All @@ -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
Expand Down Expand Up @@ -157,6 +163,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
#
Expand Down Expand Up @@ -230,7 +254,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
Expand Down Expand Up @@ -294,9 +318,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

#
Expand Down
1 change: 1 addition & 0 deletions lib/openhab/dsl/thread_local.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ module ThreadLocal

# Keys to persist
KNOWN_KEYS = %i[
openhab_context
openhab_ensure_states
openhab_holiday_file
openhab_persistence_service
Expand Down
94 changes: 94 additions & 0 deletions spec/openhab/dsl/rules/builder_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1426,6 +1426,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)
Expand All @@ -1441,6 +1452,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
Expand All @@ -1456,6 +1518,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
Expand Down Expand Up @@ -1645,6 +1726,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
Expand Down
20 changes: 20 additions & 0 deletions spec/openhab/dsl_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down

0 comments on commit 123eb88

Please sign in to comment.