[RELEASE] Switch Bindings 2.0

Ok, I just pushed out v2.0.1. It fixes the problem that was caused by the triggering device not having level, but the other device having level. You can keep on/off syncing disabled if you like, but if you turn it back on, you shouldn't have the issue. Thanks for the report!

2 Likes

I am associating two Inovelli Red dimmers (700 and 800 series) in a 3-way configuration with this app and I am running into some significant delay in the On/Off and dimming of the switches (2-3 seconds). Any advise to shorten this delay? Thank you.

The app can only sync an event after it has been received by the hub. I don't know about Inovelli (I don't have any), but my GE dimmers don't send a new level back to the hub until you release the paddle. (And even for on/off, they don't send the on/off event until they've finished their ramp-up/down, which takes about 2 seconds.)

My recommendations:

  1. Turn on debug logging on one of your dimmers. Watch the log page as you do on, off, dim up, and dim down. See when the hub receives the events.
  2. Version 2.0 added an ability to respond to "held" events and then push that to the other dimmers as a command to start ramping immediately. This helps dimming propagate immediately. However, you have to turn it on in the app settings, and you have to know how your device reports "held" events. My GE dimmers report "hold up" as a button push on button #1, and "hold down" as a button push on button #2.

Thank you for your response. I think I was able to figure things out to reduce lag time. Inovelli is weird with the way that they handle button presses with their switches. Thank you for making an easy to use Z wave association app.

Were you able to fix it by adjusting app settings? Or did you have to edit my code? Thanks!

I did by adjusting the app settings and thoroughly reading the Inovelli association guide. They suggested that the switch with the line be the master and the switch with the load be the companion. I selected the master switch in the app and then for whatever reason I needed to activate the one way binding only for the delay to resolve.

This app is not the same as creating z-wave associations, for those Inovelli switches you may have better luck using actual associations. I think if you use the Inovelli drivers they also have an association app which will create the z-wave associations on the devices.

Yeah I tried the official Inovelli Z wave association tool with poor results. I associated parameters 2 and 4 on the switches. I will continue testing this app and depending on my results I'll maybe try the Inovelli app again. Do you have any suggestions or thoughts on where I went wrong?

I dont actually have any Inovelli switches, I am just familiar with the driver code and app, and also with Z-wave associations. If done correctly, the associations in theory should be quicker especially if trying to sync dimmers with press-and-hold dimming. This requires also that the dimmer implements it correctly in its firmware.

Maybe I'll give it another try. Thanks.

Getting these errors now ....

using the 'Master' driver: GE Z-Wave Plus Motion Dimmer Component
and 'slave' driver: GE Enbrighten Z-Wave Plus Dimmer

Do you have a master switch set?

Yes. The 'Master' using the GE Z-Wave Plus Motion Dimmer Component driver.

Since 2.0 several of my bindings aren't working. I'm syncing a Generic Zigbee Switch to a couple of Advanced Zigbee RGBW Bulbs. I get errors like this:

dev:842024-03-02 11:18:24.617 AMerrorjava.lang.NullPointerException: Cannot invoke method toInteger() on null object on line 724 (method setLevel)

dev:2582024-03-02 11:18:24.584 AMerrorjava.lang.NullPointerException: Cannot invoke method toInteger() on null object on line 724 (method setLevel)

84 and 258 are the bulbs. I guess the line numbers are the build in drivers code which I can't see. It appears that your app is pushing level as Null to the driver even though I'm not syncing the level, only the switch state:

Ok, I will investigate.

1 Like

@luke2 I just pushed up v2.0.2. Can you try it out and see if it fixes your issue? Thanks!

Thanks! That seems to have fixed it :smiley:

1 Like

@jwetzel1492

I have been moving things from Room Lighting to your app as RL seems to have issues with properly updating the "Activator".
I had one binding with a Master switch (Z-Wave) and it solved my issues.
Moved 3 ZW switches over without a Master switch and it works fine.
I moved a couple of ZB bulbs over this morning and the binding throws an error when I turn on the
lights. I created a virtual scene dimmer switch - Living Room Binding and that works but if I turn on either light separatly it fails to turn on the real partner.

dev:1932024-07-24 11:45:55.222errorjava.lang.NumberFormatException: Character A is neither a decimal digit number, decimal point, nor "e" notation exponential mark. on line 455 (method setLevel)
dev:1922024-07-24 11:45:55.201errorjava.lang.NumberFormatException: Character A is neither a decimal digit number, decimal point, nor "e" notation exponential mark. on line 455 (method setLevel)

Oh, that's wild. Can you do me a favor and go into the "Apps Code" screen on your hub, click on "Switch Binding Instance", and copy the app code and paste it in here?

Thanks!

/**
 *  Switch Binding Instance v2.0.3
 *
 *  Copyright 2024 Joel Wetzel
 *
 *  Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
 *  in compliance with the License. You may obtain a copy of the License at:
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 *  Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
 *  on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
 *  for the specific language governing permissions and limitations under the License.
 *
 */

import groovy.json.*

definition(
	parent: 	"joelwetzel:Switch Bindings",
    name: 		"Switch Binding Instance",
    namespace: 	"joelwetzel",
    author: 	"Joel Wetzel",
    description: "Child app that is instantiated by the Switch Bindings app.",
    category: 	"Convenience",
	iconUrl: 	"",
    iconX2Url: 	"",
    iconX3Url: 	"")

preferences {
	page(name: "mainPage")
}


def mainPage() {
	dynamicPage(name: "mainPage", title: "Preferences", install: true, uninstall: true) {
        if (!app.label) {
			app.updateLabel(app.name)
		}
		section(getFormat("title", (app?.label ?: app?.name).toString())) {
			input(name:	"nameOverride", type: "text", title: "Custom name for this ${app.name}?", multiple: false, required: false, submitOnChange: true)

			if (settings.nameOverride) {
				app.updateLabel(settings.nameOverride)
			}
		}
		section("") {
			input(name:	"switches",	type: "capability.switch", title: "Switches to Bind", description: "Select the switches to bind.", multiple: true, required: true, submitOnChange: true)

            paragraph "<br/>Select attributes/events to sync between switches:"
            input(name: "syncOnOff", type: "bool", title: "Switch On/Off", defaultValue: true, required: true)
            input(name: "syncLevel", type: "bool", title: "Switch Level", defaultValue: true, required: true, submitOnChange: true)
            if (syncLevel) {
                input(name: "syncHeld", type: "bool", title: "Does your switch implement HELD events as button presses?", defaultValue: false, required: false, submitOnChange: true)
                if (syncLevel && syncHeld) {
                    input(name: "heldUpButtonNumber", type: "number", title: "Button number for holding UP", defaultValue: 1, required: false)
                    input(name: "heldDownButtonNumber", type: "number", title: "Button number for holding DOWN", defaultValue: 2, required: false)
                }
            }
            input(name: "syncSpeed", type: "bool", title: "Fan Speed", defaultValue: false, required: true)
            paragraph "<i>Note: Most fans also respond to level and translate it into speed.  So if syncing a dimmer and a fan, you may not need to sync speed.</i>"
            input(name: "syncHue", type: "bool", title: "Hue", defaultValue: false, required: true)
            input(name: "syncSaturation", type: "bool", title: "Saturation", defaultValue: false, required: true)
            input(name: "syncColorTemperature", type: "bool", title: "Color Temperature", defaultValue: false, required: true)
		}
		section ("<b>Advanced Settings</b>", hideable: true, hidden: false) {
			def masterChoices = [:]

			settings?.switches?.each {
				masterChoices << [(it.deviceId.toString()): it.displayName.toString()]
			}

			input(name:	"masterSwitchId",	type: "enum", title: "Select an (optional) 'Master' switch", multiple: false, required: false, submitOnChange: true, options: (masterChoices))
			def masterSwitch
            if (masterSwitchId != null) {
                masterSwitch = settings?.switches?.find { it?.deviceId?.toString() == settings?.masterSwitchId.toString() }
            }
			if (masterSwitch != null) {
				input(name:	"masterOnly", type:	"bool", title: "Bind to changes on ${masterSwitch?.displayName} only? (One-way binding instead of the normal two-way binding.)", multiple: false, defaultValue: false, submitOnChange: true)
				input(name:	'pollMaster', type:	'bool', title: "Poll ${masterSwitch.displayName} and synchronize all the devices?", defaultValue: false, required: true, submitOnChange: true)
				if (settings?.pollMaster) {
					input(name: "pollingInterval", title:"Polling Interval (in minutes)?", type: "enum", required:false, multiple:false, defaultValue:"5", submitOnChange: true,
						  options:["1", "5", "10", "15", "30"])
					if (settings.pollingInterval == null) {
                        app.updateSetting('pollingInterval', "5"); settings.pollingInterval = "5";
                    }
				}
			}

			paragraph "<br/><b>WARNING:</b> Only adjust Estimated Switch Response Time if you know what you are doing! Some dimmers don't report their new status until after they have slowly dimmed. " +
					  "The app uses this estimated duration to make sure that the bound switches don't infinitely trigger each other. Only reduce this value if you are using very fast switches, " +
					  "and you regularly physically toggle 2 (or more) of them right after each other (not a common case)."
			input(name:	"responseTime",	type: "number", title: "Estimated Switch Response Time (in milliseconds)", defaultValue: 5000, required: true)
		}
		section () {
			input(name:	"enableLogging", type: "bool", title: "Enable Debug Logging?", defaultValue: false,	required: true)
		}
	}
}


def installed() {
	log.info "Installed with settings: ${settings}"

	initialize()
}


def updated() {
	log.info "Updated with settings: ${settings}"

	unsubscribe()
	unschedule()
	initialize()
}


def initialize() {
    log "initialize()"

	def masterSwitch = settings.switches.find { it.deviceId.toString() == settings.masterSwitchId?.toString() }

	if (masterSwitch != null && settings.masterOnly) {
        // If "Master Only" is set, only subscribe to events on the  master switch.
        log "Subscribing only to master switch events"

        subscribeToEvents([masterSwitch])
    } else {
        log "Subscribing to all switch events"

        subscribeToEvents(switches)
    }

	// Generate a label for this child app
	String newLabel
	if (settings.nameOverride && settings.nameOverride.size() > 0) {
		newLabel = settings.nameOverride
	} else {
		newLabel = "Bind"
		def switchList = []

		if (masterSwitch != null) {
			switches.each {
                if (it.deviceId.toString() != masterSwitchId.toString()) {
                    switchList << it
                }
			}
		} else {
			switchList = switches
		}

		def ss = switchList.size()
		for (def i = 0; i < ss; i++) {
			if ((i == (ss - 1)) && (ss > 1)) {
				if ((masterSwitch == null) && (ss == 2)) {
					newLabel = newLabel + " to"
				}
				else {
					newLabel = newLabel + " and"
				}
			}
			newLabel = newLabel + " ${switchList[i].displayName}"
			if ((i != (ss - 1)) && (ss > 2)) {
				newLabel = newLabel + ","
			}
		}

        if (masterSwitch) {
            newLabel = newLabel + ' to ' + masterSwitch.displayName
        }
	}
	app.updateLabel(newLabel)

	atomicState.startInteractingMillis = 0 as long
	atomicState.controllingDeviceId = 0

	// If a master switch is set, then periodically resync
    if (settings.masterSwitchId && settings.pollMaster) {
		schedule("0 */${settings.pollingInterval} * * * ?", "reSyncFromMaster")
	}
}


def subscribeToEvents(subscriberList) {
    subscribe(subscriberList, "switch.on", 		'switchOnHandler')
    subscribe(subscriberList, "switch.off", 	'switchOffHandler')
    subscribe(subscriberList, "level", 			'levelHandler')
    subscribe(subscriberList, "speed", 			'speedHandler')
    subscribe(subscriberList, "hue",            'hueHandler')
    subscribe(subscriberList, "saturation",     'saturationHandler')
    subscribe(subscriberList, "colorTemperature", 'colorTemperatureHandler')
    subscribe(subscriberList, "held",           "heldHandler")
    subscribe(subscriberList, "released",       "releasedHandler")
}


void reSyncFromMaster(evt) {
	log.info "reSyncFromMaster()"

	// Is masterSwitch set?
	if (settings.masterSwitchId == null) {
		log "reSyncFromMaster: Master Switch not set"
		return
	}

    def masterSwitch = settings.switches.find { it.deviceId.toString() == settings.masterSwitchId.toString() }

	if ((now() - atomicState.startInteractingMillis as long) < 1000 * 60) {
		// I don't want resync happening while someone is standing at a switch fiddling with it.
		// Wait until the system has been stable for a bit.
		log "reSyncFromMaster: Skipping reSync because there has been a recent user interaction."
		return
	}

	def onOrOff = (masterSwitch.currentValue("switch") == "on")

	syncSwitchState(masterSwitchId, onOrOff)
}


def switchOnHandler(evt) {
	log "SWITCH On detected - ${evt.device.displayName}"

    if (checkForFeedbackLoop(evt.deviceId)) {
        return
    }

	syncSwitchState(evt.deviceId, true)
}


def switchOffHandler(evt) {
	log "SWITCH Off detected - ${evt.device.displayName}"

    if (checkForFeedbackLoop(evt.deviceId)) {
        return
    }

	syncSwitchState(evt.deviceId, false)
}


def levelHandler(evt) {
	// Only reflect level events while the switch is on (workaround for Zigbee driver problem that sends level immediately after turning off)
	if (evt.device.currentValue('switch', true) == 'off') return

    log "LEVEL ${evt.value} detected - ${evt.device.displayName}"

    if (checkForFeedbackLoop(evt.deviceId)) {
        return
    }

	syncLevelState(evt.deviceId)
}


def speedHandler(evt) {
    log "SPEED ${evt.value} detected - ${evt.device.displayName}"

    if (checkForFeedbackLoop(evt.deviceId)) {
        return
    }

	syncSpeedState(evt.deviceId)
}


def hueHandler(evt) {
    log "HUE ${evt.value} detected - ${evt.device.displayName}"

    if (checkForFeedbackLoop(evt.deviceId)) {
        return
    }

	syncHueState(evt.deviceId)
}


def saturationHandler(evt) {
    log "SATURATION ${evt.value} detected - ${evt.device.displayName}"

    if (checkForFeedbackLoop(evt.deviceId)) {
        return
    }

    syncSaturationState(evt.deviceId)
}

def colorTemperatureHandler(evt) {
    log "COLOR TEMPERATURE ${evt.value} detected - ${evt.device.displayName}"

    if (checkForFeedbackLoop(evt.deviceId)) {
        return
    }

    syncColorTemperatureState(evt.deviceId)
}

def heldHandler(evt) {
    log "HELD ${evt.value} detected - ${evt.device.displayName}"

    if (checkForFeedbackLoop(evt.deviceId)) {
        return
    }

    startLevelChange(evt.deviceId, evt.value)
}

def releasedHandler(evt) {
    log "RELEASED ${evt.value} detected - ${evt.device.displayName}"

    if (checkForFeedbackLoop(evt.deviceId)) {
        return
    }

    stopLevelChange(evt.deviceId, evt.value)
}


boolean checkForFeedbackLoop(triggeredDeviceId) {
    long now = (new Date()).getTime()

    // Don't allow feedback and event cycles.  If this isn't the controlling device and we're still within the characteristic
    // response time, don't sync this event to the other devices.
    if (triggeredDeviceId != atomicState.controllingDeviceId &&
        (now - atomicState.startInteractingMillis as long) < (responseTime as long)) {
        log "checkForFeedbackLoop: Preventing feedback loop"
        //log "preventing feedback loop variables: ${now - atomicState.startInteractingMillis as long} ${triggeredDeviceId} ${atomicState.controllingDeviceId}"
        return true
    }

    atomicState.controllingDeviceId = triggeredDeviceId
	atomicState.startInteractingMillis = now

    return false
}


def syncSwitchState(triggeredDeviceId, onOrOff) {
    if ((settings.syncOnOff != null) && !settings.syncOnOff) {
        return
    }

    def triggeredDevice = switches.find { triggeredDeviceId != null && it.deviceId.toString() == triggeredDeviceId.toString() }

	def newLevel = triggeredDevice.hasAttribute('level') ? triggeredDevice.currentValue("level", true) : null        // If the triggered device has a level, then we're going to push it out to the other devices too.
    if (newLevel != null && newLevel < 5) {
        newLevel = 5
    }

	// Push the event out to every switch except the one that triggered this.
	switches.each { s ->
		if (s.deviceId == triggeredDeviceId) {
            return
        }

        if (onOrOff) {
            // Special case for Hue bulbs.  They have a device setting for transitionTime, and to honor that, we need to setLevel with the transitionTime, instead of just turning on.
            if (s.currentValue('switch', true) != 'on' && s.getSetting("transitionTime") != null && s.hasAttribute('level') && newLevel != null) {
                def transitionTime = s.getSetting("transitionTime")
                s.setLevel(newLevel, transitionTime)
                return
            }

            if (s.currentValue('switch', true) != 'on') {
                s.on()
            }

            if (s.hasCommand('setLevel') && newLevel != null && s.currentValue('level', true) != newLevel) {        // Push the level of the triggering device (if it has one) out to the other devices. (If they support it)
                s.setLevel(newLevel)
            }
        }
        else {
            if (s.currentValue('switch', true) != 'off') {
                s.off()
            }
        }
	}
}


def syncLevelState(triggeredDeviceId) {
    if ((settings.syncLevel != null) && !settings.syncLevel) {
        return
    }

	def triggeredDevice = switches.find { it.deviceId == triggeredDeviceId }

	def newLevel = triggeredDevice.hasAttribute('level') ? triggeredDevice.currentValue("level", true) : null
    if (newLevel == null) {
        return
    }

	// Push the event out to every switch except the one that triggered this.
	switches.each { s ->
		if (s.deviceId == triggeredDeviceId) {
            return
        }

        if (s.hasCommand('setLevel')) {
            if (newLevel != null && s.currentValue('level', true) != newLevel) {
                s.setLevel(newLevel)
            }
        } else if (s.currentValue('switch') == 'off' && newLevel > 0) {
            s.on()
        }
	}
}


def syncHueState(triggeredDeviceId) {
    if ((settings.syncHue != null) && !settings.syncHue) {
        return
    }

	def triggeredDevice = switches.find { it.deviceId == triggeredDeviceId }

	def newHue = triggeredDevice.hasAttribute('hue') ? triggeredDevice.currentValue("hue", true) : null
    if (newHue == null) {
        return
    }

	// Push the event out to every switch except the one that triggered this.
	switches.each { s ->
		if (s.deviceId == triggeredDeviceId) {
            return
        }

        if (s.hasCommand('setHue') && s.currentValue('hue', true) != newHue) {
            s.setHue(newHue)
        }
	}
}


def syncSaturationState(triggeredDeviceId) {
    if ((settings.syncSaturation != null) && !settings.syncSaturation) {
        return
    }

	def triggeredDevice = switches.find { it.deviceId == triggeredDeviceId }

	def newSaturation = triggeredDevice.hasAttribute('saturation') ? triggeredDevice.currentValue("saturation", true) : null
    if (newSaturation == null) {
        return
    }

	// Push the event out to every switch except the one that triggered this.
	switches.each { s ->
		if (s.deviceId == triggeredDeviceId) {
            return
        }

        if (s.hasCommand('setSaturation') && s.currentValue('saturation', true) != newSaturation) {
            s.setSaturation(newSaturation)
        }
	}
}


def syncColorTemperatureState(triggeredDeviceId) {
    if ((settings.syncColorTemperature != null) && !settings.syncColorTemperature) {
        return
    }

	def triggeredDevice = switches.find { it.deviceId == triggeredDeviceId }

	def newColorTemperature = triggeredDevice.hasAttribute('colorTemperature') ? triggeredDevice.currentValue("colorTemperature", true) : null
    if (newColorTemperature == null) {
        return
    }

	// Push the event out to every switch except the one that triggered this.
	switches.each { s ->
		if (s.deviceId == triggeredDeviceId) {
            return
        }

        if (s.hasCommand('setColorTemperature') && s.currentValue('colorTemperature', true) != newColorTemperature) {
            s.setColorTemperature(newColorTemperature)
        }
	}
}


def syncSpeedState(triggeredDeviceId) {
    if ((settings.syncSpeed != null) && !settings.syncSpeed) {
        return
    }

	def triggeredDevice = switches.find { it.deviceId == triggeredDeviceId }

	def newSpeed = triggeredDevice.hasAttribute('speed') ? triggeredDevice.currentValue("speed") : null
    if (newSpeed == null) {
        return
    }

	// Push the event out to every switch except the one that triggered this.
	switches.each { s ->
		if (s.deviceId == triggeredDeviceId) {
            return
        }

        if (s.hasCommand('setSpeed')) {
            if (s.currentValue('speed', true) != newSpeed) {
				s.setSpeed(newSpeed)
			}
		}
	}
}


def startLevelChange(triggeredDeviceId, buttonNumber) {
    if (settings.syncHeld == null || !settings.syncHeld || settings.heldUpButtonNumber == null || settings.heldDownButtonNumber == null) {
        return
    }

    def direction = 'none'

    if (buttonNumber == settings.heldUpButtonNumber.toString()) {
        direction = 'up'
    }
    else if (buttonNumber == settings.heldDownButtonNumber.toString()) {
        direction = 'down'
    }

    if (direction == 'none') {
        return
    }

    // Push the event out to every switch except the one that triggered this.
	switches.each { s ->
		if (s.deviceId == triggeredDeviceId) {
            return
        }

        if (s.hasCommand('startLevelChange')) {
            s.startLevelChange(direction)
        }
	}
}


def stopLevelChange(triggeredDeviceId, buttonNumber) {
    if (settings.syncHeld == null || !settings.syncHeld || settings.heldUpButtonNumber == null || settings.heldDownButtonNumber == null) {
        return
    }

    // Push the event out to every switch except the one that triggered this.
	switches.each { s ->
		if (s.deviceId == triggeredDeviceId) {
            return
        }

        if (s.hasCommand('stopLevelChange')) {
            s.stopLevelChange()
        }
	}
}


def getFormat(type, myText=""){
	if(type == "header-green") return "<div style='color:#ffffff;font-weight: bold;background-color:#81BC00;border: 1px solid;box-shadow: 2px 3px #A9A9A9'>${myText}</div>"
    if(type == "line") return "\n<hr style='background-color:#1A77C9; height: 1px; border: 0;'></hr>"
	if(type == "title") return "<h2 style='color:#1A77C9;font-weight: bold'>${myText}</h2>"
}


def log(msg) {
	if (enableLogging) {
		log.debug msg
	}
}