/**
* 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 "
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 "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."
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 ("Advanced Settings", 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 "
WARNING: 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 "