Dynamically attach a rule to items that have a specific metadata

This is for Jython. Inspired by @rlkoshak’s expire replacement in which he cleverly scans items containing “expire” metadata and attaches a rule to handle the changes in the item, I made a function to do it in a generic way.

Basically I have written a few generic rules that use metadata. My previous approach was to require the items to be a member of a special group in order to invoke the rule like so:

Group MyRule
Switch MySwitch (MyRule) { MyRule=xxxx }
@rule("MyRule")
@when("Member of MyRule received update")
def myrule(event):
  # read the MyRule metadata from the item, and do something

Then by scanning the item registry for items with “MyRule” metadata, I don’t have to require items to be a member of a specific group anymore. The item definition can just be:

Switch MySwitch { MyRule=xxxx }

I’m adapting Rich’s code here (still work in progress)

from core.rules import rule
from core.triggers import when
from core.metadata import get_value
from core.log import logging, LOG_PREFIX, log_traceback
from core.jsr223.scope import items
from core.jsr223.scope import scriptExtension
ruleRegistry = scriptExtension.get("ruleRegistry")

rules_items = {}

def load_metadata_rule(metadata, handler, trigger_type='received update', cleaner=None):
    """
    Scans the Item Registry for items with the given metadata and adds the
    specified trigger type for the Item using the given handler.
    """
    log = logging.getLogger("{}.Metadata Rule Loader".format(LOG_PREFIX))
    log.debug("Loading metadata rule '{}'".format(metadata))

    # Keep track of items configured this pass
    new_items = []

    # Scan for items with the given metadata
    for item_name in items:
        metadata_found = get_value(item_name, metadata)
        if metadata_found:
            new_items.append(item_name)
            if metadata not in rules_items:
                rules_items[metadata] = {}
            rules_items[metadata][item_name] = rules_items.get(metadata, {}).get(item_name)
            log.info("Found {} with '{}'".format(item_name, metadata))

    # Remove existing rule
    if hasattr(handler, "UID"):
        ruleRegistry.remove(handler.UID)
        delattr(handler, "triggers")
        delattr(handler, "UID")

    # Generate triggers
    for item_name in new_items:
        when("Item {} {}".format(item_name, trigger_type))(handler)

    # Create the rule
    if hasattr(handler, "triggers"):
        rule(metadata)(handler)
        if hasattr(handler, "UID"):
            log.info("Rule {} loaded successfully".format(metadata))
        else:
            log.error("Failed to create {} rule".format(metadata))
    else:
        log.info("Rule {} found no configured items".format(metadata))

    # Drop items that no longer exist or no longer have the metadata
    if metadata in rules_items:
        for item_name in rules_items[metadata]:
            if item_name not in new_items:
                if item_name in items:
                    log.debug("Removing item '{}' as it no longer has a valid {} config".format(item_name, metadata))
                else:
                    log.debug("Removing item '{}' as it no longer exists".format(item_name))
                if cleaner:
                    cleaner(item_name, metadata)
                rules_items[metadata].pop(item_name)
                if not rules_items[metadata]:
                    rules_items.pop(metadata)

So now in my rule file:

def my_rule(event):
  # the implementation of my rule

def my_other_rule(event):
  #some other rule

@rule("Load MyRule")
@when("System started")
def load_myrule(event):
  load_metadata_rule('MyRule', my_rule, 'received update')
  load_metadata_rule('MyOtherRule', my_other_rule)

An improvement to this, which @rlkoshak would also like to have I’m sure, is to automatically reload the triggers whenever there’s a change in the item registry. Can it be done?

1 Like

To play devil’s advocate, what’s the advantage to this instead of using a Group?

  • It’s simpler to add a Group to an Item
  • The REST API is much more capable with it comes to Groups and Group membership
  • Groups have built in capabilities like aggregation and nesting (there is a “Decendent of” trigger for example that adds all direct members and all members of subgroups to the triggers).

See Fix rule not being deleted by CrazyIvan359 · Pull Request #2 · rkoshak/openhab-helper-libraries · GitHub. @CrazyIvan359 submitted updates to the Expire binding code that does this. If I understand it correctly, periodically it deletes the Rule and recreates it, picking up changes to the metadata.

I always appreciate your wisdom, as demonstrated here.

I think the only downside to using a group is that I’d have to create a group and add that group to the item (or the item to the group). This just makes the item definition a bit messier. So this is just purely laziness.

The benefit of dynamic attachment without using a group, is a simpler way of assigning the rule. Basically it becomes as easy as the “expire” binding, the user doesn’t have to create a group and assign items to a group just to get the rule’s functionality.

The downside of this dynamic attachment without using a group is that you’d have to keep track of it manually, although right now it’s the same with using a group, i.e. you’d still have to reload the rule file whenever group memberships change. I hope this is a temporary situation though.

That PR is only 3 lines. If I understand it correctly, that just fixes the rule deletion. It doesn’t have anything to do with periodic reloading.

Not yet… Design Pattern: Using Item Metadata as an Alternative to Several DPs - #6 by 5iver. BTW, metadata is not stoired in the ItemRegistry. There is a separate MetadataRegistry.

I just don’t see how (MyGroup) is messier than { MyMetadata=xxx }. One advantage of the metadata is you can encode additional information, as the Expire code does. But if you don’t have extra information you need to encode than the metadata is more awkward than the Group because whether you need it or not, you have to provide a value for the metadata. You can’t just use { MyMetadata }.

The need to create the Group isn’t that onerous and they give you a number of additional built in features like aggregation functions, usability on the sitemap, simpler Rule triggers, ability to send a command to all members of the Group, etc.

I’m just not sure that saving one line in a .items file is worth the change from (MyGroup) to { MyMetadata=xxx }. I wouldn’t say it’s wrong by any means, I just don’t see the problem this actually solves.

But it’s no less work to assign metadata to the Item. And it’s really awkward to do so using the REST API and for now PaperUI can’t do it at all (I need to play with Yannick’s new UI more to see what it can do with Item metadata). This lack cuts off a whole bunch of users from being able to use this. Also, VSCode is Group aware but not metadata aware. So you can get discovery and code completion and see the state of a Group from within VSCode.

The only reason I used this approach for the Expire Binding replacement was because I wanted to minimize the changes necessary for users to migrate their Rules and Items to OH 3 with the understanding that, as a 1.x version binding, OH 3 will not support the Expire binding. And when you don’t have the binding installed, the OH 1.x binding config becomes interpreted as Item Metadata. This was also done under the assumption that there would be some sort of replacement for Expire built into the core’s new scheduler which is what the users should migrate to.

And I definitely see this being useful for cases where, like the Expire Binding, you need to encode extra stuff in the metadata in additional to simply tagging an Item to trigger the Rule. I just can’t see though how this is any better than, and in some ways worse than Groups in the rest of the use cases.

That PR is a fix to an earlier PR submitted by CrazyIvan359. If you look at the full file you will see the code that runs to look for changes in the Item metadata and adjusts the rule’s triggers appropriately (look at expire_load).

Most, if not all of the rules to which this applies do indeed require extra parameters, so the Metadata is a must to start with. It’s either Group + Metadata, or just Metadata.

I am making / building a collection of rules as I go along. Here is the list of what I’ve got so far:

  • ActionRule: Perform different actions when an item received an update, depending on the value of the update

  • DetectTrend Detect rising or falling values over time

  • AutoOff: A simpler version of the expire binding. It sends the OFF command when the specified time is up

  • DimmerToControl: Make the item control a Dimmer item

In each of those rules, additional parameters are required. For example:

String MyControlKnob { DimmerToControl="MyLight_Dimmer" }

Further configurations are possible using the metadata [ x=“y”, … ] syntax.

This makes my rules generic enough that they can be applied / used by any item, without having to resort to item / group naming DP which I started with.

Right now I’m using Group membership to trigger the rules. The other benefits of Groups (hierarchical, membership, the ability to send a command / update to all members, etc) don’t seem to apply, at least I can’t think of how it could be relevant. So using Group is simply a crutch in this instance, and being able to dynamically attach the rule is a good thing imo.

Not being able to apply the metadata through paperui might be a disadvantage, however for the time being my rules are just for me. I would publish them on github when they are stable (not changing every minute). Not sure if anyone else would even use them anyhow. So even for paperui users, they would still have to set up the whole jython environment with /conf/automations/ directory and drop the files into the right place. Are there people who do this and still only use paperui to manage their items?

The idea is that these would be “reusable” / drop in rules in the same vein of what is offered by the helper libraries, i.e. similar to your expire binding replacement.

Are you aware of any actual plan in OH3 to make it so that jython scripts can get notified whenever there’s a change in the ItemRegistry or the MetadataRegistry?

Ahh… it was in a previous PR that had already been merged to your fork. Yes it does have a way to reload, but only based on detecting when the reload_item is being sent a command to do it. In other words, it is still a manually triggered reload.

As it is now, as far as I can see, the reload doesn’t happen automatically.

One might ask whether a Profile might be more appropriate for some of these. Particularly the DimmerToControl. Obviously, the barrior to creating a Profile is higher than Rules, but that use case in particular really feels like a profile.

AutoOff is just a specific case of Expire.

Which I believe I’ve seen mentioned have both already been submitted as add-ons that are installable like any other binding or add-on. The need to do that should be going away if that’s the case. There already is a beta version of the add-on and configure Jython.

Yes.

One important thing about the Expire binding replacement is, and I have always said this, it is only intended as a temporary bridge that users can use until they migrate to what ever replacement for Expire in OH 3, assuming there will be one. It’s not intended to be something that people use first. It’s not intended to be an example for a “good” way to do things. Were I to design the script from scratch without trying to make it a temporary bridge for Expire binding users, I would probably use a different approach entirely.

There are a number of use cases that it cannot support and I’m not certain that it’s as easy to user and configure as it could be if I were not beholden to how Expire works now. Ultimately it would be most appropriate to use as a Profile too, only there are a couple of limitations with Profiles that prevent it, mainly a Profile requires a Channel which eliminates a number of existing Expire binding use cases.

A cron triggered rule to command the reload Item would be the approach to automatically trigger the reload on a schedule. Without an event to subscribe to from OH itself all you can do is manually trigger it or set up polling.

@JimT I thought that I had setup a cron trigger for this exact purpose, but perhaps that was something else I wrote using the same pattern. I can confirm that I have something running on my OH that does this exact thing and it works well. The only downside is the unneeded cycles spent manually checking for changes, but this is the only way until the group/metadata change event hook is added.

How often do you run the check / reload?

Is it possible, or preferable to not remove / recreate the rule, but instead, just remove/recreate/add/delete the triggers on the existing rule, in order to reduce the processing cycle?

I’m on my phone so going from memory, but it runs every 5 or 15 minutes. It’s possible it’s Selector (I think I made a PR for it on the Helper Libraries repo à while back).

I briefly looked into this and decided it wasn’t worth the complexity for me. I believe it is possible, but would require low level modification of the rule not supported by the helper libraries.

Have you ever encountered a situation where the rule didn’t fire because it was reloading while the event occurred?

No I haven’t, but I have 2 styles of this setup and both handle that possibility.

The style like expire maintains a dictionary of timers related to items. When the rule is being refreshed it only deletes the timers for items that no longer have metadata. With the timers being preserved, the “rule” in this case is actually fired by the timer, and so is never missed.

The other style is where a dynamic set of triggers are created and tied to a rule. This one can easily miss events because the rule is deleted as part of the unload function and created near the end of the load function. In the cases where I’m using this style it is doing things based on the state of an item, so as part of the normal load function it reads the state of all relevant items and applies any changes that would have been caused by the transition to that state triggering the rule. This could end up sending a bunch of commands to items already in the desired state, but I am checking for that and skipping those commands.