Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support providing and accessing context in rules #133

Merged
merged 1 commit into from
Sep 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1212,6 +1212,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)
jimtng marked this conversation as resolved.
Show resolved Hide resolved
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
ccutrer marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -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
#
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

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