Design Pattern: Switch & Dimmer

Tags: #<Tag:0x00007f74535afd88> #<Tag:0x00007f74535afcc0> #<Tag:0x00007f74535afb30>

Please see Design Pattern: What is a Design Pattern and How Do I Use Them to understand the scope and purpose of this and other Design Patterns.

Problem Statement

When using a switch to control a dimmable light, one may want to achieve the same behavior than classical switches with an electrical relay behind the scenes:

  • a short press on the switch turns the light on or off
  • holding down the switch for a longer time will alternating increase or decrease the light

In order to avoid this complex behavior within a rule that controls the light, we want to be able to react on the different states of the switch individually:

  • Short press of the switch (push & release within a certain amount of time, e.g. 500ms)
  • Holding down the switch (recurrent iterations, e.g. after each 500ms while held down)

Concept

Use Unbound Item (aka Virtual Item) for the events and separate the concern of controlling the switch with individual rules.

Simple Example

In this example we will show how to handle a single switch.

Things

Typically, one has a Rocker Switch, e.g. the enocean FT55 (single or double). Let’s assume we have a single switch with on channel.

Items

// Real Rocker Switch
Switch MySwitch1 "Intelligent Switch 1" <switch> {channel="enocean:rockerSwitch:********:********:rockerswitchA"}

// Virtual items
Switch MySwitch1Up "Switch 1 Up" <switch>
Switch MySwitch1UpShortPress "Switch 1 Up: Short Press" <switch>
Switch MySwitch1UpLongPress "Switch 1 Up: Long Press" <switch>
Switch MySwitch1Down "Switch 1 Down" <switch>
Switch MySwitch1DownShortPress "Switch 1 Down: Short Press" <switch>
Switch MySwitch1DownLongPress "Switch 1 Down: Long Press" <switch>

Rules

import java.util.Map

val Map<String, Timer> Timers = newHashMap
val Map<String, DateTime> Timestamps = newHashMap


val SwitchPressed = [ Map<String, Timer> Timers, Map<String, DateTime> Timestamps, String id |
	Timestamps.put(id, now)
	sendCommand(id, "ON")

	// Start timer if switch is held down
	val offset = now.plusMillis(500)
	Timers.put(id, createTimer(offset, [|
		// Handle long press
		sendCommand(id + "LongPress", "ON")
		sendCommand(id + "LongPress", "OFF")
	
		// Reschedule timer for this switch
	// (max. 10 times, according to the times used in this example)
	// You may want to change the times or remove the if statement completely.
		if (now.isBefore(Timestamps.get(id)?.plusSeconds(5))) {
			val offset = now.plusMillis(500)
			Timers.get(id)?.reschedule(offset)
		}
	]))
]

val SwitchReleased = [ Map<String, Timer> Timers, Map<String, DateTime> Timestamps, String id |
	// Deactivate the timer (if running)
	Timers.get(id)?.cancel()
	Timers.remove(id)

	// Get timestamp of switch pressed down
	var pressed = Timestamps.get(id)
	Timestamps.remove(id)

	// Handle short press
	val offset = pressed?.plusMillis(500)
	if (now.isBefore(offset)) {
		sendCommand(id + "ShortPress", "ON")
		sendCommand(id + "ShortPress", "OFF")
	}

	sendCommand(id, "OFF")
]

rule "Switch1Events"
when
	Channel "enocean:rockerSwitch:********:********:rockerswitchA" triggered
then
	switch (receivedEvent.event) {
		case "DIR1_PRESSED"  : { SwitchPressed.apply(Timers, Timestamps, "MySwitch1Up") }
		case "DIR1_RELEASED"  : { SwitchReleased.apply(Timers, Timestamps, "MySwitch1Up") }
		case "DIR2_PRESSED"  : { SwitchPressed.apply(Timers, Timestamps, "MySwitch1Down") }
		case "DIR2_RELEASED"  : { SwitchReleased.apply(Timers, Timestamps, "MySwitch1Down") }
	}
end

Applications

On/Off & Dimmer Switch

This example of applications show, how we can use a single switch to control a color light with the following rules:

  • A short press on the switch will turn the light on or off
  • Holding down the switch will alternating increase or decrease the brightness of the light
  • When the light is switched off, the next dimmer action will be set to “increase”
  • When the light is switched on, the next dimmer action will be set to “decrease”

This is the most natural behavior according to classical dimmer switches with a relay.

Note: If you are using persistence, then you can replace the global command variable and simply compare the last two brightness values of the light.

// May be replaced by previousState if persistence is available!
var String MyColorLightCommand = "INCREASE"

rule "MyColorLight_OnOff"
when
	Item MySwitch1UpShortPress received command ON
then
	val color = MyColorLight.state as HSBType
	if (color.brightness == 0) {
		MyColorLightCommand = "INCREASE"
		MyColorLight.sendCommand(ON)
	} else {
		MyColorLightCommand = "DECREASE"
		MyColorLight.sendCommand(OFF)
	}
end

rule "MyColorLight_Dimmer"
when
	Item MySwitch1UpLongPress received command ON
then
	MyColorLight.sendCommand(MySwitch1Command)
end

rule "MyColorLight_CommandToggle"
when
	Item MySwitch1Up received command OFF
then
	if (MyColorLightCommand == "INCREASE") {
		MyColorLightCommand = "DECREASE"
	} else {
		MyColorLightCommand = "INCREASE"
	}
end

Theory of Operation

Every time the switch is used the special rules for handling of the switch will determine the state of the switch (whether or not it is pressed and release immediately or held down). Those rules with set the state of the virtual items accordingly. This clearly separates the concern of the switch from the actual control of the lights or whatever you want to control with the switch and allows to differentiate between short and long presses of the switch.

Advantages and Limitations

The major advantage of this approach is it centralizes the control of the switch and separate it from the actual command that will be send on activation. This allows:

  • avoidance of duplicated code
  • avoidance of typos and coding errors scattered through the code
  • ease of debugging
  • simpler Rules logic for Rules that differentiate between long and short presses of the switch

The major limitation of this approach is that it does introduces a little time delay for the actual execution of the control logic of the light.

Related Design Patterns

Design Pattern How It’s Used
Design Pattern: Unbound Item (aka Virtual Item) vTimeOfDay, Irrigation, and other Items are examples of a Virtual Item
Design Pattern: Separation of Behaviors 7 This DP is a specific implementation of Separation of Behaviors
4 Likes

Thanks for posting and for following my design pattern format. :smiley:

I’ve just a couple of comments.

The import of DateTime is not necessary. It’s imported for you.

This is probably not necessary. The Timer gets cancelled before the variable gets set to null so you should never have a case where this timer body is running and Switch1_Timer is null. But even if this is occurring because of something I don’t understand, you can write it more cleanly using

Switch1_Timer?.reschedule(now.plusMillis(globalDelayMillisForDimmer))

which means the same thing only it only takes one line of code.

Similarly, in your Switch1Released Rule you can use the ? on the cancel.

Switch1_Timer?.cancel
Switch1_Timer = null

Beyond that the code is very clear and easy to follow. Thanks again for posting!

One other limitation of this approach is related to the limitation of Thing Channel triggers. You can’t use something like Member of triggers so that this one Rule could handle ALL of your switches. So unfortunately you would have to repeat these two Rules for each of your switches. So if one has more than a couple such switches, it might be worth creating a “complex example” that puts the logic into a lambda that the Rules triggered by the channel triggers can call. This will also require more than one Timer so you may want to use a Map<String, Timer> to store them.

Hi, thanks for your ideas & comments. Well, I really like the idea of the design patterns and therefore I am happy I can contribute.

I am very new to openhab and therefore have to get around some aspects of the programming model for rules. With your hints I could rephrase the rules a bit, but it is not working, as I am experiencing errors around the Map (missing reference to Object) and functions “put & get” not being available.

Here is my current solution, that should toggle the :

val int globalDelayMillisForDimmer = 500
val Map<String, Timer> SwitchTimers = newHashMap
val Map<String, DateTime> SwitchTimestamps = newHashMap


val SwitchPressed = [ String id |
	logInfo("TMP", "SwitchPressed lambda called for " + id)
	SwitchTimestamps.put(id, now)

	// Start timer if switch is held down
	val offset = now.plusMillis(globalDelayMillisForDimmer)
	SwitchTimers.put(id, createTimer(offset, SwitchHeldDown.apply(id)))
]

val SwitchHeldDown = [ String id |
	logInfo("TMP", "SwitchHeldDown lambda called for " + id)

	// Handle long press
	sendCommand(id + "LongPress", ON)
	sendCommand(id + "LongPress", OFF)
	
	// Reschedule timer for this switch
	val offset = now.plusMillis(globalDelayMillisForDimmer)
	SwitchTimers.get(id)?.reschedule(offset)
]

val SwitchReleased = [ String id |
	logInfo("TMP", "SwitchRelease lambda called for " + id)
	
	// Deactivate the timer (if running)
	SwitchTimers.get(id)?.cancel()
	SwitchTimers.remove(id)

	// Get timestamp of switch pressed down
	var pressed = SwitchTimestamps.get(id)
	SwitchTimestamps.remove(id)

	// Handle short press
	val offset = pressed.plusMillis(globalDelayMillisForDimmer)
	if (offset.isBefore(now)) {
		sendCommand(id + "ShortPress", ON)
		sendCommand(id + "ShortPress", OFF)
	}
]

rule "temp1"
when
	Time cron "0 * * * * ?" or
	Time cron "20 * * * * ?" or
	Time cron "40 * * * * ?"
then
	SwitchPressed.apply("MySwitch1")
end

rule "temp2"
when
	Time cron "5 * * * * ?" or
	Time cron "25 * * * * ?" or
	Time cron "45 * * * * ?"
then
	SwitchReleased.apply("MySwitch1")
end

Maybe one has an idea to fix that Map issue, then I can continue on testing. If those lambdas are ready to go, i think the whole design pattern will be neat to use. :slight_smile:

My bad. You don’t have to import DateTime, but you do need to import java.util.Map.

I tried that already, but it still gives the following errors:

2019-02-07 19:43:25.015 [ERROR] [ntime.internal.engine.ExecuteRuleJob] - Error during the execution of rule 'temp2': cannot invoke method public abstract java.lang.Object java.util.Map.get(java.lang.Object) on null
2019-02-07 19:43:40.027 [ERROR] [ntime.internal.engine.ExecuteRuleJob] - Error during the execution of rule 'temp1': cannot invoke method public abstract java.lang.Object java.util.Map.put(java.lang.Object,java.lang.Object) on null

You have to pass the maps into the lambdas. Global lambdas do not have a context. They cannot see any other global vals or vars. You must pass them as arguments.

Ahh, ok, one needs to know that as well. :wink:

Then finally, I finished the sample, but I am still having one issue regarding the call of sendCommand(String, String):

import java.util.Map

val Map<String, Timer> Timers = newHashMap
val Map<String, DateTime> Timestamps = newHashMap


val SwitchPressed = [ Map<String, Timer> Timers, Map<String, DateTime> Timestamps, String id |
	logInfo("TMP", "SwitchPressed lambda called for " + id)
	Timestamps.put(id, now)

	// Start timer if switch is held down
	val offset = now.plusMillis(500)
	Timers.put(id, createTimer(offset, [|
		logInfo("TMP", "SwitchHeldDown lambda called for " + id)

		// Handle long press
		logInfo("TMP", "sendCommand(" + id + "LongPress, ON)")
		//sendCommand(id + "LongPress", ON)
		logInfo("TMP", "sendCommand(" + id + "LongPress, OFF)")
		//sendCommand(id + "LongPress", OFF)
	
		// Reschedule timer for this switch
		val offset = now.plusMillis(500)
		Timers.get(id)?.reschedule(offset)
	]))
]

val SwitchReleased = [ Map<String, Timer> Timers, Map<String, DateTime> Timestamps, String id |
	logInfo("TMP", "SwitchRelease lambda called for " + id)
	
	// Deactivate the timer (if running)
	Timers.get(id)?.cancel()
	Timers.remove(id)

	// Get timestamp of switch pressed down
	var pressed = Timestamps.get(id)
	Timestamps.remove(id)

	// Handle short press
	val offset = pressed?.plusMillis(500)
	if (now.isBefore(offset)) {
		logInfo("TMP", "sendCommand(" + id + "ShortPress, ON)")
		//sendCommand(id + "ShortPress", ON)
		logInfo("TMP", "sendCommand(" + id + "ShortPress, OFF)")
		////sendCommand(id + "ShortPress", OFF)
	}
]

rule "temp1"
when
	Time cron "0 * * * * ?" or
	Time cron "20 * * * * ?" or
	Time cron "40 * * * * ?"
then
	SwitchPressed.apply(Timers, Timestamps, "Switch1")
end

rule "temp2"
when
	Time cron "3 * * * * ?" or
	Time cron "23 * * * * ?" or
	Time cron "43 * * * * ?"
then
	SwitchReleased.apply(Timers, Timestamps, "Switch1")
end

rule "temp3"
when
	Time cron "10 * * * * ?" or
	Time cron "30 * * * * ?" or
	Time cron "50 * * * * ?"
then
	SwitchPressed.apply(Timers, Timestamps, "Switch1")
	SwitchReleased.apply(Timers, Timestamps, "Switch1")
end

When commenting those lines in, I get errors like:

java.lang.IllegalStateException: Could not invoke method: org.eclipse.smarthome.model.script.actions.BusEvent.sendCommand(java.lang.String,java.lang.String) on instance: null

For some reasons, it seems that the call for sendCommand within a lambda will fail. Could you give me another hint here? With that in place, you can images from the rules temp1-3 how this could be easily be used for all the Switch Items. :slight_smile:

Solved it, by changing to

sendCommand(id + "ShortPress", "ON")
// etc.

after reading your post at: sendCommand() Documentation :slight_smile:

Obvious, but I missed it, that the call clearly needs two Strings. :wink:

Updated the initial post and rules, plus added an application example for a dimmer switch.

2 Likes

Updated the example and introduced a stop for the rescheduler. So after 10 times, the long press action will stop to reschedule itself. This is due to the fact, that sometimes the RELEASE was not received by openhab and led to endless rescheduling. My bad. :slight_smile:

I’m going through all of the DP postings and posting at a minimum Python versions of the code. Here is a Python version of the above. I never used this DP so this code may contain some errors. Please let me know if you encounter any.

from org.joda.time import DateTime
from core.actions import ScriptExecution
from core.rules import rule
from core.triggers import when

timers = {}
timestamps = {}

def pressed_timer(id):
    events.sendCommand("{}LongPress".format(id), "ON")
    events.sendCommand("{}LongPress".format(id), "OFF")

    if DateTime.now().isBefore(timestamps[i].plusSeconds(5)):
        timers[id].reschedule(DateTime.now().plusMillis(500))

@rule("Switch1Events")
@when("Channel enocean:rockerSwitch:********:********:rockerswitchA triggered")
def switch1(event):

    direction = "Up" if event.event.startswith("DIR1") else "Down"

    # switch pressed
    if event.event.endswith("PRESSED"):
        id = "MySwitch1{}".format(direction)

        timestamps[id] = DateTime.now()
        events.sendCommand(id, "ON")

        timers[id] = ScriptExecution.createTimer(
            DateTime.now().plusMillis(500), 
            lambda: pressed_timer(id))

    # switch released
    else:
        id = "MySwitch1{}".format(direction)

        if id in timers:
            timers[id].cancel()
            del timers[id]

        pressed = timestamps[id]
        del timestamps[id]

        if DateTime.now().isBefore(pressed.plusMillis(500):
            events.sendCommand("{}ShortPress".format(id), "ON")
            events.sendCommand("{}ShortPress".format(id), "OFF")

        events.sendCommand(id, "OFF")

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

my_color_light_command = "INCREASE"

@rule("MyColorLight_OnOff")
@when("Item MySwitch1UpShortPress received command ON")
def mycolorlight_onoff(event):
    color = items["MyColorLight"]
    my_color_light_command = "DECREASE"
    cmd = "OFF"
    if color.brightness == PercentType(0):
        my_color_light_command = "INCREASE"
        cmd = ON
     events.sendCommand("MyColorLight", cmd)

@rule("MyColorLight_Dimmer")
@when("Item MySwitch1UplongPress received command ON")
def mycolorlight_dimmer(event):
    events.sendCommand("MySwitch1Light", my_color_light_command)

@rule("MyColorLight_CommandToggle"
@when("Item MySwitch1Up received command OFF")
def mycolorlight_toggle(event):
    my_color_light_command = "INCREASE" if my_color_light_command == "DECREASE" else "DECREASE"