[Jython] Bedside lights Tradfri hack - one remote, two actions

I recently ported my earlier Ikea triple-click hack from Rules DSL to Jython.

It’s much more responsive and elaborate than the original version, as it verifies that the items are grouped in triple-click association groups which belong to the gTripleToggle group.

You can add one Switch Item to multiple triple-click association groups.

The script will gracefully manage many item/group configuration errors.

Ideally I could trigger a re-initialization if I’m able to see changes in the gTripleToggle group.

Here’s the rule file (triple_click.py):

"""
This rule implements triple-click support for ITEA TRADFRI remotes by using timers and click count.
"""

from core.rules import rule
from core.triggers import when

from core.actions import LogAction

import pprint

pp = pprint.PrettyPrinter(indent=4)

# Example using the createTimer Action
from core.actions import ScriptExecution
from org.joda.time import DateTime


# Keep track of rule initialization
initialized = False

# Triple-click item timers:
timers = {}
# Triple-click item click counters:
clicks = {}
# Final state if triple-click event occurred:
end_states = {}
# Triple-click subgroup items
group_items = {}

TRIPLE_TOGGLE_GROUP_NAME = "gTripleToggle"
MONITORING_TIME_SECONDS = 3


def myRule_initialize():
    logTitle = "initialize()"
    global initialized
    LogAction.logDebug(
        logTitle,
        "AT START OF METHOD - intialized == {initialized}".format(initialized=str(initialized)),
    )

    global TRIPLE_TOGGLE_GROUP_NAME
    global timers
    global clicks
    global end_states
    global group_items

    # Verify that item exists
    if not itemRegistry.getItems(TRIPLE_TOGGLE_GROUP_NAME):
        LogAction.logError(
            logTitle,
            "Item '{name}' does not exist! Please create this Group item.".format(
                name=TRIPLE_TOGGLE_GROUP_NAME
            ),
        )
        return

    # Verify thet item is of type Group
    g = itemRegistry.getItem(TRIPLE_TOGGLE_GROUP_NAME)
    if g.type != "Group":
        LogAction.logError(
            logTitle,
            " Item '{name}' is of type '{type}', expecting 'Group'".format(
                name=TRIPLE_TOGGLE_GROUP_NAME, type=g.type
            ),
        )
        return

    # We're now okay to proceed, as the item named TRIPLE_TOGGLE_GROUP_NAME exists and is a Group
    LogAction.logDebug(
        logTitle,
        "Item '{name}' is of type '{type}'".format(name=TRIPLE_TOGGLE_GROUP_NAME, type=g.type),
    )

    # Report if incorrect Item types are assigned as direct members of TRIPLE_TOGGLE_GROUP_NAME
    tripleToggleGroupsErrors = list(item for item in g.getMembers() if item.type != "Group")
    if tripleToggleGroupsErrors:
        LogAction.logError(
            logTitle,
            "{count} Non-Group Item(s) found as direct members of '{name}'".format(
                name=TRIPLE_TOGGLE_GROUP_NAME, count=len(tripleToggleGroupsErrors)
            ),
        )
    for item in tripleToggleGroupsErrors:
        LogAction.logError(
            logTitle,
            "Item '{name}' is of type '{type}', expecting Group -- Item will be ignored".format(
                name=item.name, type=item.type
            ),
        )

    # Now get all TripleToggle group names (they must be defined as direct members of TRIPLE_TOGGLE_GROUP_NAME and of thype Group)
    tripleToggleGroups = list(item for item in g.getMembers() if item.type == "Group")
    for item in tripleToggleGroups:
        LogAction.logInfo(
            logTitle, "Found TripleToggle [{type}] '{name}'".format(type=item.type, name=item.name)
        )

    # Process the members of the TripleToggle group

    for item in (i for i in g.getAllMembers() if i.type != "Switch"):
        LogAction.logError(
            logTitle,
            "Item '{name}' is of type '{type}', expecting 'Switch' -- Item will be ignored".format(
                name=item.name, type=item.type
            ),
        )

    for item in (i for i in g.getAllMembers() if i.type == "Switch"):
        LogAction.logInfo(logTitle, "[{type}] {name}".format(type=item.type, name=item.name))
        # Determine the triple-click group(s) the Switch item belongs to:
        item_triple_toggle_group_names = list(
            group.name for group in tripleToggleGroups if group.name in item.getGroupNames()
        )
        item_triple_toggle_group_count = len(item_triple_toggle_group_names)

        if item_triple_toggle_group_count == 0:
            LogAction.logError(
                logTitle,
                "Item '{name}' belongs to 0 triple-click groups, probably direct descendant of '{groupname}' -- Item will be ignored".format(
                    name=item.name, groupname=TRIPLE_TOGGLE_GROUP_NAME
                ),
            )
        else:
            if item_triple_toggle_group_count > 1:
                LogAction.logWarn(
                    logTitle,
                    "Item '{name}' belongs to {count} triple-click groups: '{groups}'".format(
                        name=item.name,
                        count=str(item_triple_toggle_group_count),
                        groups="', '".join(item_triple_toggle_group_names),
                    ),
                )
            else:
                LogAction.logInfo(
                    logTitle,
                    "Item '{name}' belongs to 1 triple-click group(s): '{groups}'".format(
                        name=item.name, groups=item_triple_toggle_group_names
                    ),
                )

            # Now initialize the Item:
            group_items[item.name] = item_triple_toggle_group_names
            timers[item.name] = None
            clicks[item.name] = 0
            end_states[item.name] = None
            LogAction.logInfo(
                logTitle,
                "Item '{name}' of type '{type}' has been initialized".format(
                    type=item.type, name=item.name
                ),
            )

    # Initialization complete
    initialized = True
    LogAction.logDebug(
        logTitle,
        "AT END OF METHOD - intialized == {initialized}".format(initialized=str(initialized)),
    )


@rule(
    "TripleClick - System started", description="Initialize system state when the rule is reloaded"
)
@when("System started")
def SystemStarted(event):
    logTitle = "SystemStarted()"
    global initialized

    myRule_initialize()


@rule("TripleClick - Click")
@when("Descendent of gTripleToggle changed")
def myRuleTripleClick_Clicked(event):

    logTitle = "myRuleTripleClick_Clicked"
    logPrefix = (
        "(event is None): "
        if event is None
        else "Item '{name}' of type '{type}' with state '{state}': ".format(
            name=event.itemName,
            type=itemRegistry.getItem(event.itemName).type,
            state=str(event.itemState),
        )
    )
    LogAction.logDebug(logTitle, logPrefix + "At start of rule")

    global initialized
    if not initialized:
        LogAction.logWarn(logTitle, logPrefix + "Not yet initialized - no action will be taken yet")
        return

    if event is None:
        LogAction.logWarn(logTitle, logPrefix + "event == None")
        return

    if isinstance(event.itemState, UnDefType):
        LogAction.logWarn(
            logTitle,
            logPrefix
            + "event item '{name} has state '{state}".format(
                name=event.itemName, state=str(event.state)
            ),
        )
        return

    # We're good to go

    global timers
    global clicks
    global end_states
    global group_items

    LogAction.logDebug(
        logTitle, logPrefix + "group_items = {list}".format(list=pp.pformat(group_items))
    )

    name = event.itemName

    tripleToggleGroups = group_items.get(name)

    # Bail out if Item conventions for this rule are not respected
    if tripleToggleGroups is None:
        LogAction.logError(
            logTitle,
            logPrefix
            + "Item '{name}' has no triple-click groups defined -- nothing to do".format(name=name),
        )
        return

    LogAction.logDebug(
        logTitle,
        logPrefix
        + "AT START OF RULE - '{name}' (in group(s) '{groups}') has state '{state}' - starting the logic".format(
            name=name, groups="', '".join(tripleToggleGroups), state=event.itemState
        ),
    )

    if timers.get(name) is None:

        # Define the call-back that will be executed when the timer expires
        def cb():
            timers[name] = None
            clicks[name] = 0

        # We're using OH timers here as I want to reinitialize a running timer after each click (feature only available with OH timers)
        timers[name] = ScriptExecution.createTimer(DateTime.now().plusSeconds(3), cb)
        clicks[name] = 1
        # Store the desired end state (current state of triggeringItem)
        end_states[name] = str(event.itemState)

    else:
        cnt = clicks[name] + 1
        stateInfo = end_states[name]
        if cnt >= 3:
            LogAction.logInfo(
                logTitle,
                logPrefix
                + u"{name} (end state will be {stateInfo}) toggle count: {count} ≥ 3 -- Switching {stateInfo} all associated items".format(
                    name=name, stateInfo=stateInfo, count=cnt
                ),
            )
            for g in tripleToggleGroups:
                LogAction.logInfo(
                    logTitle,
                    logPrefix + "Processing items relating to group [{g}]".format(g=str(g)),
                )
                for i in itemRegistry.getItem(g).getAllMembers():
                    LogAction.logInfo(
                        logTitle,
                        logPrefix
                        + "Processing item [{i}] of type [{t}] relating to group [{g}]".format(
                            i=i.name, t=i.type, g=str(g)
                        ),
                    )
                    events.sendCommand(i, end_states[name])
                    if not timers[i.name] is None:
                        timers[i.name].cancel()
                        timers[i.name] = None
                        clicks[i.name] = 0
            LogAction.logInfo(
                logTitle,
                logPrefix
                + "Processing item triple-toggle ended for item [{name}]".format(name=name),
            )
        else:
            timers[name].reschedule(DateTime.now().plusSeconds(MONITORING_TIME_SECONDS))
            clicks[name] = cnt

    LogAction.logDebug(logTitle, logPrefix + "At end of rule")
2 Likes

Here’s the openHAB3 version. It makes use of the new semantic model in which equipment is organised in Group items. The toggle code doesn’t require you to create a dedicated Switch item on the Dimmer channel to work. Also, Joda time has been replaced with Java time:

"""
This rule implements triple-click support for IKEA remotes (e.g., TRADFRI series) by using timers and click count.
"""

from core.rules import rule
from core.triggers import when

from core.actions import LogAction

import pprint

pp = pprint.PrettyPrinter(indent=4)

# Example using the createTimer Action
from core.actions import ScriptExecution
from java.time import ZonedDateTime, LocalTime
from java.time.format import DateTimeFormatter


# openHAB 3 DateTimeType state format: 2021-05-17T11:15:00.000+0200
OH_DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSZ")


class defaults:
    TRIPLE_TOGGLE_GROUP_NAME = "gTripleToggle"
    DEFAULT_TOGGLE_TIMER_SECONDS = 3


# Keep track of rule initialization
initialized = False

# Triple-click item timers:
timers = {}
# Triple-click item click counters:
clicks = {}
# Final state if triple-click event occurred:
end_states = {}
# Triple-click subgroup items
group_items = {}


rule_init_timestamp = ZonedDateTime.now()
logTitle = "triple_click.py@{ts}".format(
    ts=rule_init_timestamp.format(DateTimeFormatter.ISO_INSTANT),
)
ruleTimeStamp = " -- (Rule set initialised at {ts})".format(
    ts=rule_init_timestamp.format(DateTimeFormatter.ISO_INSTANT),
)
rulePrefix = "Triple-Click | "


def on_load():
    """Rule initialization
    """
    global logTitle
    logPrefix = "on_load(): "
    global initialized
    LogAction.logDebug(
        logTitle,
        logPrefix
        + "AT START OF METHOD - intialized == {initialized}".format(
            initialized=str(initialized)
        ),
    )

    global timers
    global clicks
    global end_states
    global group_items

    # Verify that item exists
    if not itemRegistry.getItems(defaults.TRIPLE_TOGGLE_GROUP_NAME):
        LogAction.logError(
            logTitle,
            "Item '{name}' does not exist! Please create this Group item.".format(
                name=defaults.TRIPLE_TOGGLE_GROUP_NAME
            ),
        )
        return

    # Verify that item is of type Group
    g = itemRegistry.getItem(defaults.TRIPLE_TOGGLE_GROUP_NAME)
    if g.type != "Group":
        LogAction.logError(
            logTitle,
            " Item '{name}' is of type '{type}', expecting 'Group'".format(
                name=defaults.TRIPLE_TOGGLE_GROUP_NAME,
                type=g.type
            ),
        )
        return

    # We're now okay to proceed, as the item named defaults.TRIPLE_TOGGLE_GROUP_NAME exists and is a Group
    LogAction.logDebug(
        logTitle,
        "Item '{name}' is of type '{type}'".format(
            name=defaults.TRIPLE_TOGGLE_GROUP_NAME,
            type=g.type
        ),
    )

    # Report if incorrect Item types are assigned as direct members of defaults.TRIPLE_TOGGLE_GROUP_NAME
    tripleToggleGroupsErrors = list(
        item for item in g.getMembers() if item.type != "Group"
    )
    if tripleToggleGroupsErrors:
        LogAction.logError(
            logTitle,
            "{count} Non-Group Item(s) found as direct members of '{name}'".format(
                name=defaults.TRIPLE_TOGGLE_GROUP_NAME,
                count=len(tripleToggleGroupsErrors),
            ),
        )
    for item in tripleToggleGroupsErrors:
        LogAction.logError(
            logTitle,
            "Item '{name}' is of type '{type}', expecting Group -- Item will be ignored".format(
                name=item.name, type=item.type
            ),
        )

    # Now get all TripleToggle group names (they must be defined as direct members of defaults.TRIPLE_TOGGLE_GROUP_NAME and of thype Group)
    tripleToggleGroups = list(item for item in g.getMembers() if item.type == "Group")
    for item in tripleToggleGroups:
        LogAction.logDebug(
            logTitle,
            "Found TripleToggle [{type}] '{name}'".format(
                type=item.type, name=item.name
            ),
        )

    # Process the members of the TripleToggle group

    # Valid toggle members are of type: Switch, Dimmer
    for item in (i for i in g.getAllMembers() if i.type not in [ "Switch", "Dimmer"]):
        LogAction.logError(
            logTitle,
            "Item '{name}' is of type '{type}', expecting 'Switch' or 'Dimmer' -- Item will be ignored".format(
                name=item.name, type=item.type
            ),
        )

    for item in (i for i in g.getAllMembers() if i.type in [ "Switch", "Dimmer"] ):
        LogAction.logInfo(
            logTitle, "[{type}] {name}".format(type=item.type, name=item.name)
        )
        # Determine the triple-click group(s) the Switch item belongs to:
        item_triple_toggle_group_names = list(
            group.name
            for group in tripleToggleGroups
            if group.name in item.getGroupNames()
        )
        item_triple_toggle_group_count = len(item_triple_toggle_group_names)

        if item_triple_toggle_group_count == 0:
            LogAction.logError(
                logTitle,
                "Item '{name}' belongs to 0 triple-click groups, probably direct descendant of '{groupname}' -- Item will be ignored".format(
                    name=item.name, groupname=defaults.TRIPLE_TOGGLE_GROUP_NAME
                ),
            )
        else:
            if item_triple_toggle_group_count > 1:
                LogAction.logWarn(
                    logTitle,
                    "Item '{name}' belongs to {count} triple-click groups: '{groups}'".format(
                        name=item.name,
                        count=str(item_triple_toggle_group_count),
                        groups="', '".join(item_triple_toggle_group_names),
                    ),
                )
            else:
                LogAction.logInfo(
                    logTitle,
                    "Item '{name}' belongs to 1 triple-click group(s): '{groups}'".format(
                        name=item.name, groups=item_triple_toggle_group_names
                    ),
                )

            # Now initialize the Item:
            group_items[item.name] = item_triple_toggle_group_names
            timers[item.name] = None
            clicks[item.name] = 0
            end_states[item.name] = None
            LogAction.logInfo(
                logTitle,
                "Item '{name}' of type '{type}' has been initialized".format(
                    type=item.type, name=item.name
                ),
            )

    # Initialization complete
    initialized = True
    LogAction.logDebug(
        logTitle,
        "AT END OF METHOD - intialized == {initialized}".format(
            initialized=str(initialized)
        ),
    )


# Initialize the script when it is reloaded:
on_load()


@rule(
    rulePrefix + "Process state toggle of triple-toggle item",
    description="""When a triple-toggle item toggled its state, we will keep track of the number of times the item toggles state in a 3-second time interval
    managed by means of a timer. Each time the item state toggles, the timer is reinitialised and the click count incremented. When the click count reaches 3
    before the timer expires, then all items in the triple-toggle group(s) to which the toggled item belongs, will receive the same state as the end state
    of the toggled item. In other words, if the item was on (off), triple-toggling the item state will turn it off (on), as well as all items in the triple-toggle groups
    to which the toggled item belongs."""
    + ruleTimeStamp,
    tags=["triple-click", ruleTimeStamp],
)
@when(
    "Descendent of {group_name} changed".format(
        group_name=defaults.TRIPLE_TOGGLE_GROUP_NAME
    )
)
def myRuleTripleClick_Clicked(event):
    global logTitle
    logPrefix = "myRuleTripleClick_Clicked" + (
        "(event is None): "
        if event is None
        else "Item '{name}' of type '{type}' with state '{state}': ".format(
            name=event.itemName,
            type=itemRegistry.getItem(event.itemName).type,
            state=str(event.itemState),
        )
    )
    LogAction.logDebug(logTitle, logPrefix + "At start of rule")

    global initialized
    if not initialized:
        LogAction.logWarn(
            logTitle, logPrefix + "Not yet initialized - no action will be taken yet"
        )
        return

    if event is None:
        LogAction.logWarn(logTitle, logPrefix + "event == None")
        return

    if isinstance(event.itemState, UnDefType):
        LogAction.logWarn(
            logTitle,
            logPrefix
            + "event item '{name} has state '{state}".format(
                name=event.itemName, state=str(event.state)
            ),
        )
        return

    # We're good to go

    global timers
    global clicks
    global end_states
    global group_items

    name = event.itemName
    logPrefix += "clicks = " + str(clicks.get(name)) + " - "
    LogAction.logDebug(
        logTitle,
        logPrefix + "group_items = {list}".format(list=pp.pformat(group_items)),
    )

    tripleToggleGroups = group_items.get(name)

    # Bail out if Item conventions for this rule are not respected
    if tripleToggleGroups is None:
        LogAction.logError(
            logTitle,
            logPrefix
            + "Item '{name}' has no triple-click groups defined -- nothing to do".format(
                name=name
            ),
        )
        return

    LogAction.logDebug(
        logTitle,
        logPrefix
        + "AT START OF RULE - '{name}' (in group(s) '{groups}') has state '{state}' - starting the logic".format(
            name=name, groups="', '".join(tripleToggleGroups), state=event.itemState
        ),
    )

    if timers.get(name) is None:

        # Define the call-back that will be executed when the timer expires
        def cb():
            global logTitle
            LogAction.logInfo(
                logTitle,
                "Triple-toggle timer for item '{name}' expired. Click count was {clicks} - will be reset to 0.".format(
                    name=name, clicks=str(clicks[name])
                ),
            )
            timers[name] = None
            clicks[name] = 0

        # We're using OH timers here as I want to reinitialize a running timer after each click (feature only available with OH timers)
        timers[name] = ScriptExecution.createTimer(
            ZonedDateTime.now().plusSeconds(defaults.DEFAULT_TOGGLE_TIMER_SECONDS), cb
        )
        clicks[name] = 1
        # Store the desired end state (current state of triggeringItem)
        end_states[name] = str(event.itemState)

    else:
        cnt = clicks[name] + 1
        stateInfo = end_states[name]
        if cnt >= 3:
            LogAction.logInfo(
                logTitle,
                logPrefix
                + u"{name} (end state will be {stateInfo}) toggle count: {count} ≥ 3 -- Switching {stateInfo} all associated items".format(
                    name=name, stateInfo=stateInfo, count=cnt
                ),
            )
            for g in tripleToggleGroups:
                LogAction.logDebug(
                    logTitle,
                    logPrefix
                    + "Processing items relating to group [{g}]".format(g=str(g)),
                )
                for i in itemRegistry.getItem(g).getAllMembers():
                    LogAction.logDebug(
                        logTitle,
                        logPrefix
                        + "Processing item [{i}] of type [{t}] relating to group [{g}]".format(
                            i=i.name, t=i.type, g=str(g)
                        ),
                    )
                    LogAction.logInfo(
                        logTitle,
                        u"Will issue: events.sendCommand({i}, {state})".format(
                            i=i.name, state=end_states[name]
                        ),
                    )
                    events.sendCommand(i, end_states[name])
                    if not timers[i.name] is None:
                        timers[i.name].cancel()
                        timers[i.name] = None
                        clicks[i.name] = 0
            LogAction.logDebug(
                logTitle,
                logPrefix
                + "Processing item triple-toggle ended for item [{name}]".format(
                    name=name
                ),
            )
        else:
            timers[name].reschedule(
                ZonedDateTime.now().plusSeconds(defaults.DEFAULT_TOGGLE_TIMER_SECONDS)
            )
            clicks[name] = cnt

    LogAction.logDebug(logTitle, logPrefix + "At end of rule")