Design Pattern: Debounce

Tags: #<Tag:0x00007efecbc1dbc8>

EDIT: Added information about the openHAB 3 JavaScript MainUI rules implementation.

Please see Design Pattern: What is a Design Pattern and How Do I Use Them for a desciption of DPs.

Purpose

Debounce, sometimes called anti-flapping, is a process where either updates to an Item are ignored after a certain initial state is received, or more commonly, waiting for an Item to stay in a given state before accepting the new state. This is useful for situations like:

  • a sensor may be faulty and occasionally flap back and forth rapidly (obviously you should fix the sensor but this can be a temporary fix)
  • there is a period of rapid changes and one should wait until the changes stop before processing the most recent change
  • cases like presence detection to avoid sending the house to away mode when only away for a very short period of time such as picking up the mail.

Concept

There are two Items, one linked to the sensor that needs to be debounced and another proxy Item. Only when the sensor Item has remained in it’s given state for long enough is that state transferred to the proxy Item. Note, that the sensor Item can be a Group.

Examples

Simple Example

We will be debouncing the Switch Person1PresenceSensor so that Person1Presence is not considered away until they have been away for more than 2 minutes and not considered present until they’ve been present for more than 2 minutes. When transferring the state to Person1Presence, use an update.

Library implementations

The libraries depend on Item metadata. Two Items must be defined for each value that is to be debounced. One gets updated with the raw sensor values and the other is a proxy that gets updated with the debounced values.

The raw Item must have debounce metadata defined.

Switch Person1PresenceSensor { debounce="Person1Presence"[timeout="2m"] } // Sensor for Person 1's presence
Switch Person1Presence           // Proxy for Person1

The library has optional parameters where you can only debounce certain states and send a command to the proxy instead of an update. A more generic example for something like a button:

Switch ButtonSensor { debounce="Button"[timeout="0.25s" command="True"] }
Switch Button

With the above Items, the ButtonSensor must remain in a given state for 250 msec before the state is commanded to Button.

See the readme for more details.

JavaScript MainUI Rules

There is an implementation of this design pattern available at https://github.com/rkoshak/openhab-rules-tools that can be copied and used in openHAB 3. Create a Debounce Group. Then create two Items for every one that you want to debounce. One is linked to the source of the data. The other is a proxy that holds the debounced value.

The Item that is linked to the source of the data must be added to the Debounce Group and have debounce medata defined.

Create a new rule in MainUI, click on the code tab, and paste the contents of the YAML file into the form. Save the rule.

Python

There is a debounce library script available at https://github.com/rkoshak/openhab-rules-tools that can be downloaded and installed. To debounce the above Item all that is required is adding metadata to the sensor Item.

Rules DSL

var Timer timer = null

rule "Debounce Person1"
when
    Item Person1PresenceSensor changed
then
    timer?.cancel

    timer = createTimer(now.plusMinutes(2), [ |
        if(Person1Presence.state != Person1PresenceSensor.state)
            Person1Presence.postUpdate(Person1PresenceSensor.state)
        timer = null
    ])
end

Theory of Operation

When Person1PresenceSensor changes state, cancel any existing Timer and then set a Timer for two minutes. When Person1PresenceSensor has remained in the same state for two minutes, update Person1Presence with the current state of Person1PresenceSensor.

Complicated Example

This will be an implementation Generic Presence Detection where we have multiple people and multiple sensors per person. If any one sensor detects a person as present that person is considered immediately present. If all sensors for a person are OFF for two minutes or more, that person is considered away. When all people are away, the main presence switch is turned OFF with a command.

Items

Group:Switch:OR(ON, OFF) Present

Switch Person1_Present (PresenceSensors)
Group:Switch:OR(ON, OFF) Person1_PresenceSensors
Switch Person1_PresenceSensor1
Switch Person1_PresenceSensor2

Switch Person2_Present (PresenceSensors)
Group:Switch:OR(ON, OFF) Person2_PresenceSensors
Switch Person2_PresenceSensor1
Switch Person2_PresenceSensor2

Python

In this use case, we are only debouncing the OFF command by two minutes with a command. With the above mentioned debounce library, the following changes are required to the Items.

Group:Switch:OR(ON, OFF) Person1_PresenceSensors { debounce="Person1_Present"[timeout="2m", states="OFF", command="True"] }

Group:Switch:OR(ON, OFF) Person2_PresenceSensors { debounce="Person2_Present"[timeout="2m", states="OFF", command="True"] }

Rules DSL

import java.util.Map
import org.eclipse.smarthome.model.script.ScriptServiceUtil

val Map<String, Timer> timers = newHashMap

rule "Presence detection debounce"
when
    Item Person1PresenceSensors changed from OFF to ON or
    Item Person1PresenceSensors changed from ON to OFF or
    Item Person2PresenceSensors changed from OFF to ON or
    Item Person2PresenceSensors changed from ON to OFF
then

    // Cancel the timer if it exists
    timers.get(triggeringItem.name)?.cancel
    
    val delay = if(triggeringItem.state == ON) 0 else 2 // if ON update proxy immediately
    timers.put(triggeringItem.name, createTimer(now.plusMinutes(delay), [ |
        // get the proxy Item
        val proxyName = triggeringItem.name.split("_").get(0) + "_Present"
        val proxyItem = ScriptServiceUtil.getItemRegistry.getItem(proxyName)

        // Command if different
        if(proxyItem.state != triggeringItem.state) proxyItem.sendCommand(triggeringItem.state)

        // clear the timer
        timers.put(triggeringItem.name, null)
    ])
end

Theory of Operation

When all of the sensors for a person go to OFF, the Person1PresenceSensors Group will go to OFF through the Group aggregation function. Any existing Timer is cancelled and a new one is created which will go off in two minutes. After two minutes without changing from OFF, the OFF command is commanded to the Proxy Item which is determined using Design Pattern: Associated Items if it’s different from the Proxy Item’s current state.

When an ON command is received, the Timer for this Item is cancelled, if it exists, and a Timer is created to execute immediately which will command the Proxy Item to ON if it isn’t ON already.

Advantages and Disadvantages

When using the Python library, there is no code to write at all. All that is required is adding some Item metadata to the sensor Item and adding a proxy Item. The approach over all provides a generic way to delay processing of changes until it’s know n to be a good state in a general way.

The major disadvantage with the Rules DSL example is that it requires inserting the code into your specific use case (e.g. see Generic Presence Detection).

Related Design Patterns

Design Pattern How it’s used
Design Pattern: Associated Items Used in the Rules DSL example to convert the sensor Item’s name to it’s proxy name
Design Pattern: Motion Sensor Timer A similar but subtly different approach that is more suitable to cases where updates drive the command to the proxy Item instead of changes.
6 Likes

Hi @rlkoshak,

I tried to implement your debounce pattern by using the JS through the MainUI, but unfortunately I failed. Below some points that I found:

  • debounce.yml in the repository is not a yml, but just JS
  • state should be states in line 21+ 38
  • end_debounce should be end_debounce_generator in line 82
  • return function() in line 58 did not work in my case. I removed the function and moved the return; after the command/postUpdate

After those changes, the debouncing is still not working. The Presence Item is set to OFF immediately.

2020-12-11 10:44:31.976 [INFO ] [.openhab.model.script.Rules.Debounce] - PresenceSensors changed to ON with is not debouncing
2020-12-11 10:44:31.977 [INFO ] [.openhab.model.script.Rules.Debounce] - End debounce for Presence new state = ON curr state = OFF
2020-12-11 10:44:34.157 [INFO ] [.openhab.model.script.Rules.Debounce] - Debouncing PresenceSensors with proxy = Presence timeout = 1m and states = OFF
2020-12-11 10:44:34.164 [INFO ] [.openhab.model.script.Rules.Debounce] - End debounce for Presence new state = OFF curr state = ON

Weird, I must have copied wrong. It’s supposed to be the full YAML. I’ll get that fixed as soon as I get to a computer. It’s stuff like this that I filed an issue to have a file import/export instead of copy and paste.

No, state is correct. When I first wrote the Python version where I defined the metadata it only supported one state. I’ve since expanded to to support more than one state but to avoid breaking things I kept the metadata unchanged. You can see on line 38 that I pull the metadata using “state”, not “states”.

Therefore, the error message on line 21 is correct. The metadata should use “state” not “states”.

Actually it should be end_debounce_generator(arg, arg, arg)(). Clearly I didn’t test the case where the state isn’t debounced. :frowning:

Didn’t work how? The function is written in that way on purpose to solve a problem with scope. end_debounce_generator returns a function() because the Timer expects a fucntion(). But if you create an anonymous function (e.g. function() { end_debounce(arg1, arg2, arg3); } ), arg1, arg2, and arg3 do not get fixed to be the values they had at the time the anonymous function was created. Instead they will have the values the last time the rule ran. So I call a function to create the function() so that the argument values get fixed and remain the values that they were when the function was generated so they don’t get overwritten by later runs of the rule.

I’m not surprised as some of your fixes were incorrect fixes.

Try the following YAML:

triggers:
  - id: "1"
    configuration:
      groupName: Debounce
    type: core.GroupStateChangeTrigger
conditions: []
actions:
  - inputs: {}
    id: "2"
    configuration:
      type: application/javascript
      script: >
        var logger =
        Java.type("org.slf4j.LoggerFactory").getLogger("org.openhab.model.script.Rules.Debounce");


        // Get Metadata query stuff

        this.FrameworkUtil = (this.FrameworkUtil === undefined) ? Java.type("org.osgi.framework.FrameworkUtil") : this.FrameworkUtil;

        this._bundle = (this._bundle === undefined) ? FrameworkUtil.getBundle(scriptExtension.class) : this._bundle;

        this.bundle_context = (this.bundle_context === undefined) ? this._bundle.getBundleContext() : this.bundle_context;

        this.MetadataRegistry_Ref = (this.MetadataRegistry_Ref === undefined) ? bundle_context.getServiceReference("org.openhab.core.items.MetadataRegistry") : this.MetadataRegistry_Ref;

        this.MetadataRegistry = (this.MetadataRegistry === undefined) ? bundle_context.getService(MetadataRegistry_Ref) : this.MetadataRegistry;

        this.Metadata = (this.Metadata === undefined) ? Java.type("org.openhab.core.items.Metadata") : this.Metadata;

        this.MetadataKey = (this.MetadataKey === undefined) ? Java.type("org.openhab.core.items.MetadataKey") : this.MetadataKey;


        // Load TimerMgr

        this.OPENHAB_CONF = (this.OPENHAB_CONF === undefined) ? java.lang.System.getenv("OPENHAB_CONF") : this.OPENHAB_CONF;

        load(this.OPENHAB_CONF+'/automation/lib/javascript/community/timerMgr.js');


        /**
         * Get and check the item metadata.
         * @return {dict} The metadata parsed and validated
         */
        var checkMetadata = function(itemName) {
          var USAGE = "Debounce metadata should follow debounce=ProxyItem[command=true, timeout='2s', state='ON,OFF']."
          var cfg = MetadataRegistry.get(new MetadataKey("debounce", itemName));
          if(cfg === null) {
            throw itemName + " does not have debounce metadata! " + USAGE;
          }
          
          if(cfg.value === undefined || cfg.value === null) {
            throw itemName + " does not have a proxy Item defined! " + USAGE;
          }
          if(cfg.configuration["timeout"] == undefined || cfg.configuration["timeout"] === null) {
            throw itemName + " does not have a timeout parameter defined! " + USAGE;
          }
          var dict = {"proxy": cfg.value,
                      "timeout": cfg.configuration["timeout"],
                      "command": "command" in cfg.configuration && cfg.configuration["command"].toLowerCase() == "true",
                      };
                      
          var stateStr = cfg.configuration["state"];
          if(stateStr === undefined || stateStr === null) {
            throw itemName + " does not have proper debounce metadata " + cfg.toSTring;
          }
          var split = stateStr.split(",");
          dict["states"] = [];
          for(st in split) {
            dict["states"].push(split[st]);
          }
          return dict;
        }


        /**
         * Called when the debounce timer expires, transfers the current state to the 
         * proxy Item.
         * @param {string} state the state to transfer to the proxy Item
         * @param {string} name of the proxy Item
         * @param {Boolean} when true, the state is sent as a command
         */
        var end_debounce_generator = function(state, proxy, isCommand) {
            return function() {
                logger.info("End debounce for " + proxy + " new state = " + state + " curr state = " + items[proxy]);
                if(isCommand && items[proxy] != state) {
                  events.sendCommand(proxy, state);
                }
                else if (items[proxy].toString != state) {
                  events.postUpdate(proxy, state);
                }
              }
        }


        this.timers = (this.timers === undefined) ? new TimerMgr() : this.timers;

        var cfg = checkMetadata(event.itemName);


        if(cfg["states"].length == 0 || 
          (cfg["states"].length > 0 && cfg["states"].indexOf(event.itemState.toString()) >= 0)) {
          logger.info("Debouncing " + event.itemName + " with proxy = " + cfg["proxy"] 
                       + " timeout = " + cfg["timeout"] + " and states = " + cfg["states"]);
          this.timers.check(event.itemName, cfg["timeout"], 
                            end_debounce_generator(event.itemState, cfg["proxy"], cfg["command"]));    
        }

        else {
          logger.info(event.itemName + " changed to " + event.itemState + " which is not debouncing");
          end_debounce_generator(event.itemState, cfg["proxy"], cfg["command"])();
        }
    type: script.ScriptAction

Note, I’ve moved the logging to info instead of debug for now. I did a couple of tests including testing the not/debounced state and it seems to work.

But if you used “states” in your metadata, you’ll need to change them to “state”.

No, state is correct

I’m sorry I misunderstood. Based on the example above (the python version) I thought it should be “states”. Also I saw the .py says “states” in the error message
Are I’m right you are using different namespaces for python and javascript?
Within line 43+45+72+73 (debouce.yml/js) i also saw “states”.

The code in the function was not executed. However with the latest code its working, so forget about it.

Thank you Rich, with this yaml it is working!

Hmmmm. The Python version must be wrong. :smiley: Bugs all over the place.

thanks for letting me know it works. I’ll look at that Python message too. Maybe I’m just all messed up. I’m moving between OH 2.5 and OH 3 at the same time as rewriting these so mistakes are bound to happen.