Python script to automatically create multitoggle switches from a Number item - your feedback?

Hey all,
I came up with a function which might be useful to others.

The use case is:

  • you have Number item which works as a scene switch - i.e. each value of the item corresponds to one scene
  • you want to control these scenarios through Switch items, e.g. because you want to be able to use Alexa speech commands with these switches

Everything is contained in one Python file and can be controlled by adding specific tags to an item - I tried to keep the interface clean.
The setup is working for me, but I’d say I’m only 80% done - see the TODOs in the description.

I would be very interested in your feedback - what could be improved?
What did I miss?
Are there better approaches in OpenHab / Python to accomplish what I did?

Cheers,
-bastian

EDIT: the script below is outdated. Please use the script from the latest comment below on this page which has the full script.

from core.jsr223.scope import events, itemRegistry, OnOffType, ON, OFF
from core.rules import rule
from core.triggers import when
from core.items import add_item
from core.utils import post_update_if_different
#from personal.logger import Logger


"""
Usage Scenario:
- you have Number item which works as a scene switch - i.e. each value 
  of the item corresponds to one scene
- you want to control these scenarios through Switch items, e.g. because 
  you want to be able to use Alexa speech commands with these switches

This script allows just that, with minimal effort on your side.


Usage:
You need to add two tags to the Number item:
1. "hl:multitoggle" 
This tag must always be set exactly as shown.

2. "hl:config={0: 'Off', 1: 'First Scene Name', 2: 'Second scene name'}" 
The part contained in curly brackets must be a valid Pyhton dictionary, 
it may contain as many scene-items as you need.
The key must be integer indexes in ascending order, the value becomes 
the label of the corresponding scene switch and hence also the keyword 
for Alexa for that switch.
The first dictionary item with 0 as key is considered to be an Off-state
for the scenario.

Example:
  Number   NMediaScene    "Media Scene [%s]"   ["hl:multitoggle", "hl:config={0: 'Off', 1: 'Music', 2: 'Fire TV', 3: 'PS4'}"]

Function:
- setting the Number item to a specific scene, will turn the Switch 
  associated to that number to ON and all others to OFF.
- setting a Switch to ON will set the Number item to the associated 
  int value
- setting any Switch except the first one to OFF is considered switching
  the whole scenario to Off-state and thus sets the Numer item to 0
- setting the first Switch to OFF is senseless with regards to the 
  concept, as it would mean "switch off the Off state" - therefore the 
  system prevents this by setting the switch back to ON immidiately

TODO: clarify, how to remove created items if the multitoggle base item 
(Number) does not exist anymore
    workaround: delete manually in Paper UI
TODO: clarify, how to work with changes of the multitoggle base item 
(Number) (currently, items are deleted and recreated on each run)
TODO: ensure that spaces work in scene names
    workaround: don't use spaces
TODO: check if unicode chars work in scene names
    workaround: don't use unicod chars
TODO: check what happens to the Alexa items when elements are re-created 
(i.e. do you have to do a new search for items or not)
"""


def add_multitoggle_switch(name, label, tags=[], state="OFF", overwrite=True):
    if itemRegistry.getItems(name) and overwrite:
        itemRegistry.remove(name)   
    # create the switch - this will raise an exception if the Switch exists and overwrite=False
    new_toggle_switch = add_item(item_name, item_type="Switch", label=label, tags=tags)
    # set switched initially to OFF
    events.postUpdate(new_toggle_switch, "OFF")
    return new_toggle_switch


def multitoggle_trigger_generator(group_list):
    """Decorator which generates a trigger for the multitoggle rule."""
    def wrapper(function):
        for group in group_list:
            when("Member of {} received command".format(group))(function)
        return function
    return wrapper


#my_log = Logger(initial_entry=False)

# naming definitions
ns_helper_library = "hl"
multitoggle_tag = "multitoggle"
multitoggle_tag_off_state = "offstate"
config_tag = "config"
multitoggle_Group_prefix = "GMT"
multitoggle_switch_prefix = "SMT"

multitoggle_groups = []

#my_log.inspect_var(itemRegistry.getItemsByTag("hl:multitoggle"), "itemRegistry.byTag")
#my_log.inspect_var(itemRegistry.getItemsByTagAndType("Number", "hl:multitoggle"), "itemRegistry.byTagAndType")


for toggle_as_number in itemRegistry.getItemsByTagAndType("Number", ns_helper_library + ":" + multitoggle_tag):
    # iterate over all toggle-items
    #my_log.message("Setting up multitoggle for {}".format(toggle_as_number.name))

    # add a group - works as a handle to the switches
    group_name = multitoggle_Group_prefix + toggle_as_number.name
    if itemRegistry.getItems(group_name):
        itemRegistry.remove(group_name)
    toggle_group = add_item(group_name, item_type="Group", tags=[ns_helper_library + ":" + multitoggle_tag], label=group_name)

    #store the new multitoggle group
    multitoggle_groups.append(group_name)

    # include the original item in the group
    toggle_group.addMember(toggle_as_number)
    
    # grab the config info from the correct label
    for tag in toggle_as_number.tags:
        if tag.find(ns_helper_library + ":" + config_tag) != -1:
            toggle_options = eval(tag[tag.find("{"):])

            # construct a switch item for the first value - the off-state
            key, value = toggle_options.items()[0]
            item_name = multitoggle_switch_prefix + toggle_as_number.name + "_" + str(key)
            new_toggle_switch = add_multitoggle_switch(item_name, value, ["Switchable", ns_helper_library + ":" + multitoggle_tag_off_state])
            toggle_group.addMember(new_toggle_switch)

            for key, value in toggle_options.items()[1:]:
                # construct a switch item for the remaining scene values
                item_name = multitoggle_switch_prefix + toggle_as_number.name + "_" + str(key)
                new_toggle_switch = add_multitoggle_switch(item_name, value, ["Switchable"])
                toggle_group.addMember(new_toggle_switch)
            
            # sync switch state to number item
            if not isinstance(toggle_as_number.state, UnDefType) and toggle_as_number.state.intValue() >= 0:
                events.postUpdate(multitoggle_switch_prefix + toggle_as_number.name + "_" + str(toggle_as_number.state.intValue()), "ON")


@rule("Multitoggle", description="Auto generated rule to handle multitoggle.")
@multitoggle_trigger_generator(multitoggle_groups)
# @when("Member of {} received command".format("GMTNTryoutAAA"))
def multitoggle(event):
    """Auto generated rule to handle multitoggle."""
    #my_log = Logger(multitoggle, event)

    # detect the multi toggle group, where the change happened
    multitoggle_group_name = itemRegistry.getItem(event.itemName).groupNames[0]
    multitoggle_group = itemRegistry.getItem(multitoggle_group_name)
    if not multitoggle_group.hasTag(ns_helper_library + ":" + multitoggle_tag):
        raise Exception("group is expected to have the tag <{}>".format(ns_helper_library + ":" + multitoggle_tag))


    if itemRegistry.getItem(event.itemName).getType() == "Switch":
        toggle_as_number = filter(lambda item: item.getType() == "Number", multitoggle_group.members)[0]
        if event.itemCommand == OnOffType.OFF:
            if itemRegistry.getItem(event.itemName).hasTag(ns_helper_library + ":" + multitoggle_tag_off_state):
                # it's the first switch, which may never be off, as the switch itself represents the off-state
                events.postUpdate(event.itemName, "ON")
            else:
                # it's not the first switch, so the first switch needs to be set to ON
                first_switch = filter(lambda item: item.hasTag(ns_helper_library + ":" + multitoggle_tag_off_state), multitoggle_group.members)[0]
                events.postUpdate(first_switch, "ON")

                events.sendCommand(toggle_as_number, first_switch.name[first_switch.name.rfind("_")+1:])
        elif event.itemCommand == OnOffType.ON:
            # set all switches of that group to OFF, except the one which triggered the change
            for item in filter(lambda item: item.getType() == "Switch", multitoggle_group.members):
                if item.name != event.itemName:
                    post_update_if_different(item, "OFF")
            
            # set the numeric item to the number of the switch, which triggered to ON
            events.sendCommand(toggle_as_number, event.itemName[event.itemName.rfind("_")+1:])
    elif itemRegistry.getItem(event.itemName).getType() == "Number":
        # set all switches of that group to OFF, except the switch which corresponds to the selected option
        for item in filter(lambda item: item.getType() == "Switch", multitoggle_group.members):
            if item.name[item.name.rfind("_")+1:] == str(event.itemCommand):
                post_update_if_different(item, "ON")       
            else:    
                post_update_if_different(item, "OFF") 

Example item:

Number                          NMyMediaScene
    "Media Scene [%s]"              ["hl:multitoggle", "hl:config={0: 'Off', 1: 'Fernsehen', 2: 'PS4', 3: 'Musik'}"]
1 Like

Have you considered using Item metadata instead of tags? See Design Pattern: Using Item Metadata as an Alternative to Several DPs for an example. It gives you a much better way to associated this sort of information with an Item and will free you from needing to build the dictionary, you just need to query for the values. It would also be more language independent (i.e. the same Items would work with a JavaScript version of the Rules).

Have you given any thought to supporting Strings instead or in addition to Numbers? The amount of processing or CPU difference between them is almost unmeasurable in this context but with Strings you can use meaningful names instead of needing to remember that 1 means Foo and 2 means Bar and so on. You can use mappings with a String Item just like you can with a Number Item.

Hi @rlkoshak,
thank you for taking the time to go through my coding and for giving me feedback! I really apreciate that.

I saw there was something like metadata, but I had no contact with that concept in OH prior to when I wrote the above script.
Just yesterday, I switched my Homekit integration from labels to metadata. From what you say and from the fact that Homekit is moving to metadata, I understand that this is the way to go.

I agree with you, that strings will make the configuration easier to read, so this is also something I will change.
I’ll put my updated script here, once I get round to do the changes.

I have updated the script above, please use this version if you are interested in using it yourself.

Changes:

  • configuration is done via metadata now
  • the central toggle-item is of type string now
  • existing switch items are only recreated if necessary (this ensures, that Alexa does not have to redetect items)
"""
Usage Scenario:
- you have String item which works as a scene switch - i.e. a defined 
  set of string-values correspond to one scene each
- you want to control these scene through Switch items, e.g. because 
  you want to be able to use Alexa speach commands with these switches

This script allows you to do just that, with minimal effort on your 
side.


Usage:
You need to add metadata to the String item:

{multitoggle="< 'NO_OFF_SWITCH' | 'INCLUDE_OFF_SWITCH' >" [OFF="<label for off>", < KEY1 >="< LABEL1 >", < KEY2 >="< LABEL2 >"]}

example:
String                          TMyMediaScene
    "Media Scene [%s]"      
    {multitoggle="NO_OFF_SWITCH" [OFF="Ausschalten", TV="Fernsehen", PS4="PS4", MUSIC="Musik"]}

explanation
< NO_OFF_SWITCH | INCLUDE_OFF_SWITCH >
Must be set to either "NO_OFF_SWITCH" or "INCLUDE_OFF_SWITCH".
Determines whether a switch is generated to represent the OFF state. 
If such a switch exists, setting this switch to ON results in setting 
the string item to "OFF".

OFF="<label for off>"
OFF must be defined. Setting any of the generated switches to OFF will 
set the String item to "OFF".

< KEY1 >="< LABEL1 >", < KEY2 >="< LABEL2 >", < KEYn >="< LABELn >"
An arbitrary number of keys and associated values. The multitoggle will 
generate a switch item for each key-value pair. The label of a switch 
(which bevomes the voice commandin Alexa) will be set to "< LABEL >". 
If the switch is set to on, the String item will be set to "< KEY >".
< KEY >s must not contain underscores ('_').


Function:
- setting the String item to one of the predefined values, will turn the 
  corresponding Switch to ON and all others to OFF.
- setting a Switch to ON will set the String item to the associated 
  value
- setting any Switch (except the Switch which represents the OFF state) 
  to OFF is considered switching the whole scenario off and sends "OFF" 
  to the String item
- setting the Switch that represents OFF to OFF is senseless with 
  regards to the concept - therefore the system prevents this by setting 
  the switch back to ON immidiately

Notes:
- The script recreates the multitoggle setup whenever it is run 
  (scripts are run on startup of OH or when they are changed and saved).
- When you change your item-setup, you have to re-run the script to 
  reflect the changes.
- When you change the configuration in a way, that leads to the 
  generation of a new (or changed) switch item, this switch has to be 
  redetected through Alexa.
  
Known Issues
- Labels containing non-ASCII characters (e.g. German Umlaute) are shown 
  correctly in PaperUI, but are not detected correctly when using Alexa 
  to detect items. 
  Workaround: stick to ASCII

TODO: ensure that spaces work in scene names (=labels)
    workaround: don't use spaces
"""
from core.metadata import metadata_registry, \
                            set_metadata, get_value, get_key_value
from core.rules import rule
from core.triggers import when
from core.items import add_item
from core.utils import post_update_if_different
from core.log import logging, LOG_PREFIX
from collections import namedtuple
#from personal.logger import Logger

# globals
namespace = "multitoggle"
rule_managed = "RULE_MANAGED_ITEM"
flavours = [
    "INCLUDE_OFF_SWITCH",
    "NO_OFF_SWITCH",
]
group_prefix = "GMT"
switch_prefix = "SMT"
off_state = "OFF"
config_creator = "BOUND_TO"

hue_switch_tag = "Switchable"

ToggleItem = namedtuple("ToggleItem", "name value config")
Item = namedtuple("Item", "name type value config")

# helper functions
def multitoggle_trigger_generator(group_list):
    """Decorator which generates a trigger for the multitoggle rule."""
    def wrapper(function):
        for group in group_list:
            when("Member of {} received command".format(group))(function)
        return function
    return wrapper

# vars
multitoggles = []
multitoggle_groups = []


#local_log = Logger.for_script()

# collect all items with the multitoggle namespace
for toggle_metadata in [toggles for toggles in metadata_registry.getAll() 
                        if toggles.UID.namespace == namespace]:
    item = Item(
        toggle_metadata.getUID().getItemName(), 
        itemRegistry.getItem(toggle_metadata.getUID().getItemName()).getType(), 
        toggle_metadata.getValue().upper(), 
        toggle_metadata.getConfiguration(), 
    )

    # sort out items with correct type, but incorrect flavour
    if item.type == "String" and item.value not in flavours:
        logging.warning("multitoggle: item {} is marked as {}, "
            "but uses value {}, the allowed values are: {}".format(
                                                item.name, namespace, 
                                                flavours, item.value))
        continue

    # sort out items with correct type, but incorrect key(s)
    if item.type == "String" and \
                    [key for key in item.config.keys() if key.find("_") > 0]:
        logging.warning("multitoggle: item {} uses config key {} which "
            "contains '_', but underscores in keys are not allowed".format(
            item.name, [key for key in item.config.keys() \
                                                if key.find("_") > 0]))
        continue

    # sort out items with correct flavour, correct type, but missing OFF state
    if item.value in flavours and item.type == "String" \
                                        and not item.config.has_key(off_state):
        logging.warning("multitoggle: item {} is marked as {}, "
            "but has no config for off state {}".format(item.name, namespace, 
                                                        off_state))
        continue

    # check the existing rule_managed items
    if item.value == rule_managed:
        creator_item = get_key_value(item.name, namespace, config_creator)
        config_label = get_key_value(creator_item, namespace, 
                                        item.name[item.name.rfind("_")+1:])
            # the label from the current config for this item
        item_label = itemRegistry.getItem(item.name).getLabel()

        # craetor item is gone OR 
        # item is a switch and
        #   the key for this switch is not present in the config OR
        #   the value for this switch does not match the current switch label
        if creator_item not in items or (
                item.type == "Switch" and (len(config_label) == 0 or 
                                    config_label != item_label)):
            itemRegistry.remove(item.name)
        continue

    # collect correct items, for which multitoggles need to be created
    multitoggles.append(item)

# create switch- and group-items for all multitoggles
for toggle in multitoggles:
    #local_log.message("Setting up multitoggle for {}".format(toggle.name))
    toggle_item = itemRegistry.getItem(toggle.name)

    # add a group - works as a handle to the switches
    group_name = group_prefix + "_" + toggle.name
    if group_name not in items:
        toggle_group = add_item(group_name, item_type="Group", 
                                                        label=group_name)
        set_metadata(group_name, namespace, {config_creator: toggle.name}, 
                                                            rule_managed)
    else:
        toggle_group = itemRegistry.getItem(group_name)

    # remember all multitoggle groups (needed as rule triggers further below)
    multitoggle_groups.append(group_name)

    # include the original item in the group
    toggle_group.addMember(toggle_item)
    
    # construct switch items
    for key, value in toggle.config.items():
        # skip OFF item, if configuration does not want an explicit OFF switch
        if toggle.value == "NO_OFF_SWITCH" and str(key) == "OFF":
            continue
        
        item_name = switch_prefix + "_" + toggle.name + "_" + str(key)
        if not item_name in items:
            toggle_switch = add_item(item_name, item_type="Switch", 
                                        label=value, tags=[hue_switch_tag])
        else:
            toggle_switch = itemRegistry.getItem(item_name)

        # set switched initially to OFF
        events.postUpdate(toggle_switch, "OFF")
        
        set_metadata(item_name, namespace, {config_creator: toggle.name}, 
                        rule_managed)

        # include the switch item in the group
        toggle_group.addMember(toggle_switch)

    # sync switch state to toggle item
    if not isinstance(toggle_item.state, UnDefType) and \
            toggle_item.state.toString() in toggle.config.keys():
        if switch_prefix + "_" + toggle.name + "_" + \
                toggle_item.state.toString() in items:
            events.postUpdate(switch_prefix + "_" + toggle.name + "_" + 
                                toggle_item.state.toString(), "ON")


# generate rule to handle multitoggle changes
if len(multitoggle_groups) > 0:
    @rule("Multitoggle", 
            description="Auto generated rule to handle multitoggle.")
    @multitoggle_trigger_generator(multitoggle_groups)
    def multitoggle(event):
        """Auto generated rule to handle multitoggle."""
        #local_log = Logger.for_rule(multitoggle, event)

        # get the multitoggle group, from the item that triggered the event
        multitoggle_group_name = [group_name for group_name 
            in itemRegistry.getItem(event.itemName).groupNames
            if get_value(group_name, namespace) == rule_managed][0]
        multitoggle_group = itemRegistry.getItem(multitoggle_group_name)

        if itemRegistry.getItem(event.itemName).getType() == "Switch":
            toggle_as_string = [member_item for member_item 
                                in multitoggle_group.members 
                                if member_item.getType() == "String"][0]

            if event.itemCommand == OnOffType.OFF and \
                            event.itemName[-3:] == off_state:
                # the OFF switch was set to OFF, which may not happen
                events.postUpdate(event.itemName, "ON")
            elif event.itemCommand == OnOffType.OFF:
                # it's not the OFF switch, so the OFF switch needs to be 
                # set to ON if it exists (this depends on the flavour)
                if event.itemName[:event.itemName.rfind("_")] + "_OFF" \
                                                                    in items:
                    events.postUpdate(
                        event.itemName[:event.itemName.rfind("_")] + "_OFF", 
                        "ON")

                events.sendCommand(toggle_as_string, off_state)
            elif event.itemCommand == OnOffType.ON:
                # set all switches of that group to OFF, except the one which 
                # triggered the change
                for switch in [member_item for member_item \
                                in multitoggle_group.members \
                                if member_item.getType() == "Switch" \
                                    and member_item.name != event.itemName]:
                    post_update_if_different(switch, "OFF")
                
                # set the string item to the number of the switch, which 
                # triggered to ON
                events.sendCommand(toggle_as_string, 
                    event.itemName[event.itemName.rfind("_")+1:])
        elif itemRegistry.getItem(event.itemName).getType() == "String":
            # set all switches of that group to OFF, except the switch which 
            # corresponds to the selected option
            for switch in [member_item for member_item \
                            in multitoggle_group.members \
                            if member_item.getType() == "Switch"]:
                if switch.name[switch.name.rfind("_")+1:] == \
                                                    str(event.itemCommand):
                    post_update_if_different(switch, "ON")       
                else:    
                    post_update_if_different(switch, "OFF") 

Since this is a script, these things are already available in the default script scope, so these imports are not necessary.

Are you aware that you can set a StringItem through Alexa? For example…

String    Mode    "Mode [%s]"	    <house>    {alexa="ModeController.mode" [supportedModes="Morning,Day,Evening,Night,Late,Party,Sleep",ordered=false,category="OTHER"]

Thanks - noted and removed from the script in the previous post!

Dang! No, I was not - thanks for pointing this out.
I looked into this a little - do I understand correctly:
For this to work, I need to use the OpenHab Alexa Skill, which in turn requires me to open up my setup to the outside world?
It is not supported when using the Hue Emulation (which is what I am doing currently)?

You need the Alexa skill, which does not open up your setup to the outside world any more than when using Hue emulation with Alexa.

It says here:

Using the OpenHab Cloud is something I am not doing at the moment. I qould consider using it, to open up more than right now.

It needs to be installed for authentication… nothing needs to be exposed. It’s really nice to have for notifications too!

I got a different understanding from looking at the docs and also from quickly trying it out.

With the OH Alexa Skill:
speech command >> Alexa device >> Amazon Cloud (speech recogintion & skill logic) >> OH Cloud >> OH local instance.
So every time an item is switched, this travels through the OH cloud. Also, all items must be exposed to the OH cloud (or I just did not see a way to limit the cloud connector to only expose some items).

In contrary with the Hue Binding:
speech command > Alexa device >> Amazon Cloud (speech recognition) >> Alexa device >> local OH instance (via Hue Binding)
In this case, cloud services are involved for speech recognition only, anything else happens locally. Plus I can explicitly mark only those items, which should be exposed through the Hue Binding.

And please don’t get me wrong, I do want to understand how the Alexa Skill operates. You got me interested in its features when you mentioned the possibility to control modes (scenes) in your first remark. I am just trying to get a clear picture of how the skill works and what that changes (or not) about the exposure of my OH instance.

Only Items that you configure with tags or metadata are discoverable by Alexa. Unless it changed, the hueemulation service does the opposite and exposes all Items unless they are tagged to be ignored. Both are configurable.

I had the same concerns and used hueemulation until the v3 skill was released. We see no decrease in response time, functionality is much better, the hueemulation service is a mess, and we did not see any decrease in security, since we already had myopenhab.org setup for notifications. The Cloud connector only exposes Items that you specify, and we do not expose any. This is a different set of Items than what Alexa can see.

I think it would be worth continuing your research. There are topics in the forum on this subject.