Bedside lights Tradfri hack - one remote, two actions

At home we have two bedside lights and two remotes.

Instead of buying 2 additional remotes (and then not remembering which controls which light), I implemented a “hack” so that any of the 2 bedside remotes (each controlling only one bedside light) can either control the associated light or both bedside lights, as follows:

  1. One click → only operate the controlled bedside light
  2. 3 clicks within 5 seconds → operate both bedside lights

The 3-click logic will turn both bedside lights OFF if the controlled light was ON before the 3 clicks. So it’s intuitive logic: 1 click for the intended end state + 2 “do it for both lights” clicks.

The rule is implemented by using timers.

Here’s the items:

Group:Switch:OR(ON, OFF)	gLight				"All Lights [%d ON]"	<light>
Group:Dimmer				gDimmer				"All Lights [%d %%]"	<light>
Group:Dimmer				gWhiteSpectrum		"All Lights [%d %%]"	<light>

Switch	FF_MasterBedroom_MumsBedsideLight_Light_Toggle	"Mum's bedside light Light"	<light>	(gLight, gTradfriLight)]	{
	channel="tradfri:0220:gwabcdefabcdef:65544:brightness"
}
Dimmer	FF_MasterBedroom_MumsBedsideLight_Light_Dimmer	"Mum's bedside light [%d %%]"	<light>	(FF_MasterBedroom, gDimmer)	{
	channel="tradfri:0220:gwabcdefabcdef:65544:brightness"
}
Dimmer	FF_MasterBedroom_MumsBedsideLight_Light_ColorTemperature	"Mum's bedside light color temperature	[%d %%]" <light>	(gWhiteSpectrum)	{
	channel="tradfri:0220:gwabcdefabcdef:65544:color_temperature"
}

Switch	FF_MasterBedroom_DadsBedsideLight_Light_Toggle	"Dad's bedside light Light"	<light>	(gLight, gTradfriLight)	{
	channel="tradfri:0220:gwabcdefabcdef:65564:brightness"
}
Dimmer	FF_MasterBedroom_DadsBedsideLight_Light_Dimmer	"Dad's bedside light [%d %%]"	<light>	(gDimmer)	{
	channel="tradfri:0220:gwabcdefabcdef:65564:brightness"
}
Dimmer	FF_MasterBedroom_DadsBedsideLight_Light_ColorTemperature	"Dad's bedside light color temperature	[%d %%]" <light>	(gWhiteSpectrum)	{
	channel="tradfri:0220:gwabcdefabcdef:65564:color_temperature"
}

And here’s the rules code:

import java.util.HashMap

// Keep track of the per item timers:
val HashMap<String, Timer> timers = newHashMap

// Keep track of the number of clicks while the timer runs:
val HashMap<String, Integer> timer_counts = newHashMap

// Will contain the state at 1st toggle (will be the end state in successful 3-click scenario):
val HashMap<String, String> timer_states = newHashMap

// System startup delay timer (e.g. after reloading a changed rules file):
var Timer timer_Tradfri_startup = null

var boolean STARTED_UP = false


rule "System startup - Tradfri timer"
when
	System started
then
    val String logTitle = "System startup - Tradfri remotes"
    val int STARTUP_DELAY_SECONDS = 30
	logInfo(logTitle, "Initializing timers after {} seconds", STARTUP_DELAY_SECONDS)

    STARTED_UP = false;
    if (timer_Tradfri_startup === null) {
        // Wait 30 seconds before resetting all timers
        timer_Tradfri_startup = createTimer(now.plusSeconds(STARTUP_DELAY_SECONDS), [ |
            STARTED_UP = true;
            gTradfriLight.allMembers.forEach[ s |
                if (timers.get(s.name) !== null) {
                    timers.put(s.name, null)
                }
            ]
            timer_Tradfri_startup = null;
    	logInfo(logTitle, "Timers initialized")
        ])
    } else {
        timer_Tradfri_startup.reschedule(now.plusSeconds(STARTUP_DELAY_SECONDS))
    }
end


rule "A bedside light changed"
when
    Item FF_MasterBedroom_MumsBedsideLight_Light_Toggle changed
    or Item FF_MasterBedroom_DadsBedsideLight_Light_Toggle changed
then
    val String logTitle = "A bedside light changed"
    val int MONITORING_TIME_SECONDS = 5
    
    val String name = triggeringItem.name

    logDebug(logTitle, "Bedside light {} has state {} - AT START OF RULE", name, triggeringItem.state)

    if (STARTED_UP !== true) {
        logWarn(logTitle, "Not yet started up (ignoring)")
    } else {
        if (timers.get(name) === null) {
            // No timer running - start a timer:
            logDebug(logTitle, "Bedside light {} has state {} (will be the end state) - monitoring started for {} seconds", name, triggeringItem.state, MONITORING_TIME_SECONDS)
            timers.put(name, createTimer(now.plusSeconds(MONITORING_TIME_SECONDS), [ |
                logDebug(logTitle, "Bedside light {} - monitoring ended after {} seconds of inactivity", name, MONITORING_TIME_SECONDS)
                // Reset the counter and kill the timer when the timer expires:
                timer_counts.put(name, 0)
                timers.put(name, null)
            ]))
            // Set the toggle count to 1 after creating the timer:
            timer_counts.put(name, 1)
            // Store the desired end state (current state of triggeringItem)
            timer_states.put(name, triggeringItem.state.toString)
        } else {
            // Increment the click timer
            val int cnt = timer_counts.get(name) + 1
            val String stateInfo = timer_states.get(name)
            if (cnt >= 3) {
                logInfo(logTitle, "Bedside light {} (end state will be {}) toggle count: {} ≥ 3 -- Switching {} both bedside lights",
                    name, stateInfo, cnt, stateInfo)
                FF_MasterBedroom_MumsBedsideLight_Light_Toggle.sendCommand(stateInfo)
                FF_MasterBedroom_DadsBedsideLight_Light_Toggle.sendCommand(stateInfo)
                timers.get(name).cancel
                timers.put(name, null)
            } else {
                logDebug(logTitle, "Bedside light {} (end state will be {}) toggle count: {} - rescheduling the counter (adding {} seconds)",
                    name, stateInfo, cnt, MONITORING_TIME_SECONDS)
                // Restart the timer with a fresh lease:
                timers.get(name).reschedule(now.plusSeconds(MONITORING_TIME_SECONDS))
                // Store the incremented click counter:
                timer_counts.put(name, cnt)
            }
        }
    }
end

Don’t over hastily triple-click as you must allow the state changes to be picked up by OpenHAB.

Have fun!

4 Likes

I now added group logic to deploy the same approach in different rooms.

Here’s what I changed:

// Group for al triple-toggle items
Group gTripleToggle

// Triple-toggle group: all living room lights
Group gTripleToggle_LivingDining (gTripleToggle)

// Triple-toggle group: all living room lights
Group gTripleToggle_MasterBedroom (gTripleToggle)

I then assigned the relevant _Toggle items to the related gTripleToggle group, as in:

Switch	GF_LivingDining_Uplighters_Light_Toggle	"Uplighters"	<light>	(GF_LivingDining, gLight, gTradfriLight, gTripleToggle_LivingDining)	{
	channel="tradfri:0100:gwabcdefabcdef:65537:brightness,tradfri:0100:gwabcdefabcdef:65551:brightness,tradfri:0100:gwabcdefabcdef:65539:brightness,tradfri:0100:gwabcdefabcdef:65540:brightness"
}

Now the rules read as follows:

// import org.eclipse.smarthome.model.script.ScriptServiceUtil
import java.util.HashMap

val HashMap<String, Timer> timers = newHashMap

val HashMap<String, Integer> timer_counts = newHashMap

// Will contain the state at 1st toggle (will be the end state)
val HashMap<String, String> timer_states = newHashMap

// Will contain the gTripleToggle group
val HashMap<String, GroupItem> gTripleToggle_groupItems = newHashMap

var Timer timer_Tradfri_startup = null

var boolean STARTED_UP = false

rule "System startup - Tradfri TripleToggle"
when
	System started
then
    val String logTitle = "System startup - Tradfri TripleToggle"
    val int STARTUP_DELAY_SECONDS = 10 // 30
	logInfo(logTitle, "Initializing timers after {} seconds", STARTUP_DELAY_SECONDS)

    STARTED_UP = false;
    if (timer_Tradfri_startup === null) {
        // Wait 5 seconds before resetting all timers
        timer_Tradfri_startup = createTimer(now.plusSeconds(STARTUP_DELAY_SECONDS), [ |
            STARTED_UP = true;

            // Iterate over all group toggle items (by resolving the groups)
            gTripleToggle.allMembers.forEach[ GenericItem toggleItem |
                logInfo("gTripleToggle", "Checking group membership of TripleToggle item {}", toggleItem.name)
                // For each item, iterate over the TripleToggle groups and check if the item belongs to one of these toggle groups
                gTripleToggle.members.filter[ s | s instanceof GroupItem ].forEach[ GroupItem toggleGroup |
                    logDebug("gTripleToggle", "Checking if item {} belongs to TripleToggle Group {}", toggleItem.name, toggleGroup.name)
                    if ( toggleItem.getGroupNames.contains(toggleGroup.name) ) {
                        logInfo("gTripleToggle", "MACTH: item {} belongs to TripleToggle Group {}", toggleItem.name, toggleGroup.name)
                        gTripleToggle_groupItems.put(toggleItem.name, toggleGroup)
                    }
                ]
                // Now initialize the timers
                if (timers.get(toggleItem.name) !== null) {
                    timers.put(toggleItem.name, null)
                }
            ]
            timer_Tradfri_startup = null;
    	logInfo(logTitle, "Timers initialized")
        ])
    } else {
        timer_Tradfri_startup.reschedule(now.plusSeconds(STARTUP_DELAY_SECONDS))
    }
end

rule "Member of gTripleToggle changed"
when
    /* DOES NOT WORK (probably because it doesn't resolve subgroups):
    Member of gTripleToggle changed
    */
    Member of gTripleToggle_MasterBedroom changed
    or Member of gTripleToggle_LivingDining changed
then
    val String logTitle = "Member of gTripleToggle changed"
    val int MONITORING_TIME_SECONDS = 3 // Will reset each time a click has been received, so it is okay to keep it short
    
    if (STARTED_UP !== true) {
        logWarn(logTitle, "Not yet initialized - nothing to do")
    } else {
        // Started up - rule items have been initialized

        if (triggeringItem === null) {
            logWarn(logTitle, "TripleToggle - AT START OF RULE - TriggeringItem is null (after OH restart?) - nothing to do",
                name, tripleToggleGroup.name, triggeringItem.state)
        } else {
            val String name = triggeringItem.name
            val GroupItem tripleToggleGroup = gTripleToggle_groupItems.get(name)
            if ( (triggeringItem.state == NULL) || (triggeringItem.state == UNDEF) ) {
                logWarn(logTitle, "TripleToggle - AT START OF RULE - {} (in group {}) has state {} - nothing to do",
                    name, tripleToggleGroup.name, triggeringItem.state)
            } else {
                logDebug(logTitle, "TripleToggle - AT START OF RULE - {} (in group {}) has state {} - starting the logic",
                    name, tripleToggleGroup.name, triggeringItem.state)

                if (timers.get(name) === null) {
                    // No timer running - start a timer:
                    logDebug(logTitle, "TripleToggle {} has state {} (will be the end state) - monitoring started for {} seconds", name, triggeringItem.state, MONITORING_TIME_SECONDS)
                    timers.put(name, createTimer(now.plusSeconds(MONITORING_TIME_SECONDS), [ |
                        logDebug(logTitle, "TripleToggle {} - monitoring ended after {} seconds of inactivity", name, MONITORING_TIME_SECONDS)
                        // Reset the counter and kill the timer when the timer expires
                        timer_counts.put(name, 0)
                        //timer_toggling.put(name, false)
                        timers.put(name, null)
                    ]))
                    // Set the toggle count to 1
                    timer_counts.put(name, 1)
                    // Store the desired end state (current state of triggeringItem)
                    timer_states.put(name, triggeringItem.state.toString)
                } else {
                    val int cnt = timer_counts.get(name) + 1
                    val String stateInfo = timer_states.get(name)
                    if (cnt >= 3) {
                        logInfo(logTitle, "TripleToggle {} (end state will be {}) toggle count: {} ≥ 3 -- Switching {} both bedside lights",
                            name, stateInfo, cnt, stateInfo)

                        gTripleToggle_groupItems.get(name).allMembers.forEach[s |
                            // Send the command
                            s.sendCommand(stateInfo)
                            // Cancel any running timer
                            timers.get(s.name)?.cancel
                            timers.put(s.name, null)
                        ]
                    } else {
                        logDebug(logTitle, "{} (end state will be {}) toggle count: {} - rescheduling the counter (adding {} seconds)",
                            name, stateInfo, cnt, MONITORING_TIME_SECONDS)
                        timers.get(name).reschedule(now.plusSeconds(MONITORING_TIME_SECONDS))
                        timer_counts.put(name, cnt)
                    }
                }
            }
        }
    }
end

I was surprised why

    Member of gTripleToggle changed

does not work. I could however understand that it relates to the groupItem.members() versus groupItem.allMembers() logic, but it’s a bit odd that in rules, groups are not resolved to the ‘leaves’ (the actual items, irrespective of group nesting).

I implemented “Descendent of” in the Jython helper libraries a while ago…

So it is a known limitation of the BeanShell / Rules DSL Member of rule processing.

Would it make sense to issue a feature request to add support for Member of GROUP_ITEM with descendants in BeanShell Rules DSL? I’d rather use with here to avoid claiming and for this case.

In essence, it would apply the rule triggers to groupItem.AllMembers instead of GroupItem.members alone.

You could try, but development for the old rule engine has pretty much stopped and it will very likely be (I should say planned to be) replaced with the new one in OH3. IMO, no more features should be added to it, so that focus can be placed on the new rule engine. I do have a task on my list to add ‘Member of’ and ‘Descendent of’ as official ModuleTypes, along with others. And yeah… the spelling can be used either way, but the ‘a’ may be more expected for those who haven’t studied Latin… I should change that too :slightly_smiling_face:.

In other words, I should consider porting my BeanShell rules to the new JSR223/Jython format. I suppose I’ll have to rewrite my rules from scratch then?

I suggest setting it up and using it for new rules that you make. Once you have a handle on things you will want to port them :slightly_smiling_face:. The new rule engine offers so much more than the rules DSL!

There’s a choice to make though, because there are other languages that you can use, and with some development skills, you could use others. Jython, JS and Groovy are known to be in use and libraries have been built for them. The helper libraries abstract not only the automation API, but other Java classes and methods, which are pretty much all accessible. Eventually, most of this functionality will be replaced with a scripting API. The two main scripting languages are Jython (Python) and JS. IMO, Jython is by far easier to use, and the helper libraries are the most evolved, though I’m putting effort into building up JS and Groovy too.

It’s your decision when to migrate, and there is a clock faintly ticking. There’s a possibility that there may be a migration tool or (less likely) a bridge, but learning the language by doing your own conversions is really the best way to learn. The helper library documentation has very recently been reworked and there are side-by-side comparisons of all languages, including the DSL. I will be posting an announcement soon, once everything is polished and some more content has been added.

If/when you try it out and have questions, just tag a post with ‘jython’ and I’ll be glad to help!

1 Like

I now ported this rule from Rules DSL to Jython:

Although Rules DSL is a powerful language, I prefer the Jython approach.

1 Like

It’s doing what was asked of it. If some members are Groups, and those Groups do not change, then there is no trigger.
It is possible for a (sub)Group member to change and not affect the state of it’s parent Group.
This will certainly be the case with your example groups, as they have no aggregation functions at all and so their state will never change at all.
So this is all working exactly as intended.

It is fair to say that there is no DSL AllMember of ... trigger and that could be a potential enhancement. There are use cases for that method as well.

I guess this part applies to the equivalent in other rules languages too … what should happen when an Item change (or update) causes a subgroup change (or update)?
I think that should cause two trigger events for “allMembers” (or however many subgroup layers there are)

When dealing with updates, there is also the weird issue that a (sub)Group may generate multiple update events for a single member change.

The feature may be less useful than first appears, but will have its uses, especially with Groups that are only used for grouping and not aggregating functions.

Of course, the whole issue is easily circumvented by putting any Items in a “top level” Group just for the purposes of the rule. Items can be in multiple groups.