/** * GE Z-Wave Fan Controller * * Copyright 2020 Tim Grimley * NOTE- This is a BETA version, has not been throughly tested as I don't own this switch. Feel free to fork and make changes * * 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. * * Copyright 2020 Chris Nussbaum, Tim Grimley * Contributors - Bradlee_S * Thanks Chris for the original copy of this great code! * Thanks Bradlee for the button programming to get this working in the new app's automations section * * Button Mappings NOTE - THIS IS A BREAKING CHANGE from prior versions and uses a single button. * ALL prior automations will need to be re-programmed or updated when updating this DTH from old versions: * * ACTION BUTTON# BUTTON ACTION * Double-Tap Up 1 up_2x * Double-Tap Down 1 down_2x * * 0.11 (12/02/2020) - Changed to support one button with different values (see mapping note below) * 0.10 (09/23/2020) - Initial Release * */ import groovy.transform.Field import groovy.json.JsonOutput metadata { definition(name: "GE Fan Switch", namespace: "mwav3", author: "Tim Grimley", ocfDeviceType: "oic.d.fan", genericHandler: "Z-Wave") { capability "Switch Level" capability "Switch" capability "Fan Speed" capability "Health Check" capability "Actuator" capability "Refresh" capability "Sensor" capability "Button" capability "Polling" command "low" command "medium" command "high" command "raiseFanSpeed" command "lowerFanSpeed" command "doubleUp" command "doubleDown" fingerprint mfr: "0063", prod: "4944", model: "3131", deviceJoinName: "GE Fan" //GE In-Wall Smart Fan Control fingerprint mfr: "0039", prod: "4944", model: "3131", deviceJoinName: "Honeywell Fan" //Honeywell Z-Wave Plus In-Wall Fan Speed Control } simulator { status "00%": "command: 2003, payload: 00" status "33%": "command: 2003, payload: 21" status "66%": "command: 2003, payload: 42" status "99%": "command: 2003, payload: 63" } preferences { input "ledIndicator", "enum", title: "LED Indicator", description: "Turn LED indicator... ", required: false, options:["on": "When On", "off": "When Off", "never": "Never"], defaultValue: "off" input "invertSwitch", "bool", title: "Invert Switch", description: "Invert switch? ", required: false input "forceupdate", "bool", title: "Force Settings Update/Refresh?", description: "Toggle to force settings update", required: false input ( type: "paragraph", element: "paragraph", title: "Configure Association Groups:", description: "Devices in association group 2 will receive Basic Set commands directly from the switch when it is turned on or off. Use this to control another device as if it was connected to this switch.\n\n" + "Devices in association group 3 will receive Basic Set commands directly from the switch when it is double tapped up or down.\n\n" + "Devices are entered as a comma delimited list of IDs in hexadecimal format." ) input ( name: "requestedGroup2", title: "Association Group 2 Members (Max of 5):", type: "text", required: false ) input ( name: "requestedGroup3", title: "Association Group 3 Members (Max of 4):", type: "text", required: false ) } tiles(scale: 2) { multiAttributeTile(name: "fanSpeed", type: "generic", width: 6, height: 4, canChangeIcon: true) { tileAttribute("device.fanSpeed", key: "PRIMARY_CONTROL") { attributeState "0", label: "off", action: "switch.on", icon: "st.thermostat.fan-off", backgroundColor: "#ffffff" attributeState "1", label: "low", action: "switch.off", icon: "st.thermostat.fan-on", backgroundColor: "#00a0dc" attributeState "2", label: "medium", action: "switch.off", icon: "st.thermostat.fan-on", backgroundColor: "#00a0dc" attributeState "3", label: "high", action: "switch.off", icon: "st.thermostat.fan-on", backgroundColor: "#00a0dc" } tileAttribute("device.fanSpeed", key: "VALUE_CONTROL") { attributeState "VALUE_UP", action: "raiseFanSpeed" attributeState "VALUE_DOWN", action: "lowerFanSpeed" } } standardTile("refresh", "device.switch", width: 2, height: 2, inactiveLabel: false, decoration: "flat") { state "default", label: '', action: "refresh.refresh", icon: "st.secondary.refresh" } main "fanSpeed" details(["fanSpeed", "refresh"]) } } def installed() { sendEvent(name: "checkInterval", value: 2 * 15 * 60 + 2 * 60, displayed: false, data: [protocol: "zwave", hubHardwareId: device.hub.hardwareID, offlinePingable: "1"]) response(refresh()) } def parse(String description) { def result = null if (description != "updated") { log.debug "parse() >> zwave.parse($description)" def cmd = zwave.parse(description, [0x20: 1, 0x26: 1]) if (cmd) { result = zwaveEvent(cmd) } } if (result?.name == 'hail' && hubFirmwareLessThan("000.011.00602")) { result = [result, response(zwave.basicV1.basicGet())] log.debug "Was hailed: requesting state update" } else { log.debug "Parse returned ${result?.descriptionText}" } if (!device.currentValue("supportedButtonValues")) { sendEvent(name: "supportedButtonValues", value:JsonOutput.toJson(["up_2x","down_2x"]), displayed:false) } return result } def zwaveEvent(physicalgraph.zwave.commands.basicv1.BasicReport cmd) { fanEvents(cmd) } def zwaveEvent(physicalgraph.zwave.commands.associationv2.AssociationReport cmd) { log.debug "---ASSOCIATION REPORT V2--- ${device.displayName} sent groupingIdentifier: ${cmd.groupingIdentifier} maxNodesSupported: ${cmd.maxNodesSupported} nodeId: ${cmd.nodeId} reportsToFollow: ${cmd.reportsToFollow}" if (cmd.groupingIdentifier == 3) { if (cmd.nodeId.contains(zwaveHubNodeId)) { createEvent(name: "numberOfButtons", value: 1, displayed: false) } else { sendHubCommand(new physicalgraph.device.HubAction(zwave.associationV2.associationSet(groupingIdentifier: 3, nodeId: zwaveHubNodeId).format())) sendHubCommand(new physicalgraph.device.HubAction(zwave.associationV2.associationGet(groupingIdentifier: 3).format())) createEvent(name: "numberOfButtons", value: 0, displayed: false) } } } def zwaveEvent(physicalgraph.zwave.commands.basicv1.BasicSet cmd) { log.debug "---BASIC SET V1--- ${device.displayName} sent ${cmd}" if (cmd.value == 255) { createEvent(name: "button", value: "up_2x", data: [buttonNumber: 1], descriptionText: "Double-tap up (button 1 up_2x) on $device.displayName", isStateChange: true, type: "physical") } else if (cmd.value == 0) { createEvent(name: "button", value: "down_2x", data: [buttonNumber: 1], descriptionText: "Double-tap down (button 1 down_2x) on $device.displayName", isStateChange: true, type: "physical") } else { fanEvents(cmd) } } def zwaveEvent(physicalgraph.zwave.commands.configurationv2.ConfigurationReport cmd) { log.debug "---CONFIGURATION REPORT V2--- ${device.displayName} sent ${cmd}" def name = "" def value = "" def reportValue = cmd.configurationValue[0] switch (cmd.parameterNumber) { case 3: name = "indicatorStatus" value = reportValue == 1 ? "when on" : reportValue == 2 ? "never" : "when off" break case 4: name = "inverted" value = reportValue == 1 ? "true" : "false" break default: break } createEvent([name: name, value: value, displayed: false]) } def zwaveEvent(physicalgraph.zwave.commands.switchmultilevelv1.SwitchMultilevelReport cmd) { fanEvents(cmd) } def zwaveEvent(physicalgraph.zwave.commands.switchmultilevelv1.SwitchMultilevelSet cmd) { fanEvents(cmd) } def zwaveEvent(physicalgraph.zwave.commands.hailv1.Hail cmd) { log.debug "received hail from device" } def zwaveEvent(physicalgraph.zwave.Command cmd) { // Handles all Z-Wave commands we aren't interested in log.debug "Unhandled: ${cmd.toString()}" [:] } def configure() { def cmds = [] // Get current config parameter values cmds << zwave.configurationV2.configurationGet(parameterNumber: 3).format() cmds << zwave.configurationV2.configurationGet(parameterNumber: 4).format() // Add the hub to association group 3 to get double-tap notifications cmds << zwave.associationV2.associationSet(groupingIdentifier: 3, nodeId: zwaveHubNodeId).format() cmds << zwave.associationV2.associationGet(groupingIdentifier: 3).format() delayBetween(cmds,500) } def updated() { if (state.lastUpdated && now() <= state.lastUpdated + 3000) return state.lastUpdated = now() def nodes = [] def cmds = [] if (settings.requestedGroup2 != state.currentGroup2) { nodes = parseAssocGroupList(settings.requestedGroup2, 2) cmds << zwave.associationV2.associationRemove(groupingIdentifier: 2, nodeId: []) cmds << zwave.associationV2.associationSet(groupingIdentifier: 2, nodeId: nodes) cmds << zwave.associationV2.associationGet(groupingIdentifier: 2) state.currentGroup2 = settings.requestedGroup2 } if (settings.requestedGroup3 != state.currentGroup3) { nodes = parseAssocGroupList(settings.requestedGroup3, 3) cmds << zwave.associationV2.associationRemove(groupingIdentifier: 3, nodeId: []) cmds << zwave.associationV2.associationSet(groupingIdentifier: 3, nodeId: nodes) cmds << zwave.associationV2.associationGet(groupingIdentifier: 3) state.currentGroup3 = settings.requestedGroup3 } switch (ledIndicator) { case "on": indicatorWhenOn() break case "off": indicatorWhenOff() break case "never": indicatorNever() break default: indicatorWhenOff() break } switch (invertSwitch) { case "false": notInverted() break case "true": inverted() break default: notInverted() } sendEvent(name: "numberOfButtons", value: 1, displayed: false) sendEvent(name: "supportedButtonValues", value:JsonOutput.toJson(["up_2x","down_2x"]), displayed:false) sendHubCommand(cmds.collect{ new physicalgraph.device.HubAction(it.format()) }, 500) log.debug "---Preferences Updated--- ${device.displayName} sent ${cmds}" } def fanEvents(physicalgraph.zwave.Command cmd) { def rawLevel = cmd.value as int def result = [] if (0 <= rawLevel && rawLevel <= 100) { def value = (rawLevel ? "on" : "off") result << createEvent(name: "switch", value: value) result << createEvent(name: "level", value: rawLevel == 99 ? 100 : rawLevel) def fanLevel = 0 if (has4Speeds()) { fanLevel = getFanSpeedFor4SpeedDevice(rawLevel) } else { fanLevel = getFanSpeedFor3SpeedDevice(rawLevel) } result << createEvent(name: "fanSpeed", value: fanLevel) } return result } def on() { state.lastOnCommand = now() delayBetween([ zwave.switchMultilevelV3.switchMultilevelSet(value: 0xFF).format(), zwave.switchMultilevelV1.switchMultilevelGet().format() ], 5000) } def off() { delayBetween([ zwave.switchMultilevelV3.switchMultilevelSet(value: 0x00).format(), zwave.switchMultilevelV1.switchMultilevelGet().format() ], 1000) } def getDelay() { // the leviton is comparatively well-behaved, but the GE and Honeywell devices are not zwaveInfo.mfr == "001D" ? 2000 : 5000 } def setLevel(value, rate = null) { def cmds = [] def timeNow = now() if (state.lastOnCommand && timeNow - state.lastOnCommand < delay ) { // because some devices cannot handle commands in quick succession, this will delay the setLevel command by a max of 2s log.debug "command delay ${delay - (timeNow - state.lastOnCommand)}" cmds << "delay ${delay - (timeNow - state.lastOnCommand)}" } def level = value as Integer level = level == 255 ? level : Math.max(Math.min(level, 99), 0) log.debug "setLevel >> value: $level" cmds << delayBetween([ zwave.switchMultilevelV3.switchMultilevelSet(value: level).format(), zwave.switchMultilevelV1.switchMultilevelGet().format() ], 5000) return cmds } def setFanSpeed(speed) { if (speed as Integer == 0) { off() } else if (speed as Integer == 1) { low() } else if (speed as Integer == 2) { medium() } else if (speed as Integer == 3) { high() } else if (speed as Integer == 4) { max() } } def raiseFanSpeed() { setFanSpeed(Math.min((device.currentValue("fanSpeed") as Integer) + 1, 3)) } def lowerFanSpeed() { setFanSpeed(Math.max((device.currentValue("fanSpeed") as Integer) - 1, 0)) } def low() { setLevel(has4Speeds() ? 25 : 32) } def medium() { setLevel(has4Speeds() ? 50 : 66) } def high() { setLevel(has4Speeds() ? 75 : 99) } def max() { setLevel(99) } def ping() { refresh() } def getFanSpeedFor3SpeedDevice(rawLevel) { // The GE, Honeywell, and Leviton 3-Speed Fan Controller treat 33 as medium, so account for that if (rawLevel == 0) { return 0 } else if (1 <= rawLevel && rawLevel <= 32) { return 1 } else if (33 <= rawLevel && rawLevel <= 66) { return 2 } else if (67 <= rawLevel && rawLevel <= 100) { return 3 } } def getFanSpeedFor4SpeedDevice(rawLevel) { if (rawLevel == 0) { return 0 } else if (1 <= rawLevel && rawLevel <= 25) { return 1 } else if (26 <= rawLevel && rawLevel <= 50) { return 2 } else if (51 <= rawLevel && rawLevel <= 75) { return 3 } else if (76 <= rawLevel && rawLevel <= 100) { return 4 } } def has4Speeds() { isLeviton4Speed() } def isLeviton4Speed() { (zwaveInfo?.mfr == "001D" && zwaveInfo?.prod == "0038" && zwaveInfo?.model == "0002") } def doubleUp() { sendEvent(name: "button", value: "up_2x", data: [buttonNumber: 1], descriptionText: "Double-tap up (button 1 up_2x) on $device.displayName", isStateChange: true, type: "digital") } def doubleDown() { sendEvent(name: "button", value: "down_2x", data: [buttonNumber: 1], descriptionText: "Double-tap down (button 1 down_2x) on $device.displayName", isStateChange: true, type: "digital") } void indicatorWhenOn() { sendEvent(name: "indicatorStatus", value: "when on", display: false) sendHubCommand(new physicalgraph.device.HubAction(zwave.configurationV2.configurationSet(configurationValue: [1], parameterNumber: 3, size: 1).format())) } void indicatorWhenOff() { sendEvent(name: "indicatorStatus", value: "when off", display: false) sendHubCommand(new physicalgraph.device.HubAction(zwave.configurationV2.configurationSet(configurationValue: [0], parameterNumber: 3, size: 1).format())) } void indicatorNever() { sendEvent(name: "indicatorStatus", value: "never", display: false) sendHubCommand(new physicalgraph.device.HubAction(zwave.configurationV2.configurationSet(configurationValue: [2], parameterNumber: 3, size: 1).format())) } void inverted() { sendEvent(name: "inverted", value: "inverted", display: false) sendHubCommand(new physicalgraph.device.HubAction(zwave.configurationV2.configurationSet(configurationValue: [1], parameterNumber: 4, size: 1).format())) } void notInverted() { sendEvent(name: "inverted", value: "not inverted", display: false) sendHubCommand(new physicalgraph.device.HubAction(zwave.configurationV2.configurationSet(configurationValue: [0], parameterNumber: 4, size: 1).format())) } def poll() { def cmds = [] cmds << zwave.switchBinaryV1.switchBinaryGet().format() if (getDataValue("MSR") == null) { cmds << zwave.manufacturerSpecificV1.manufacturerSpecificGet().format() } delayBetween(cmds,500) } def refresh() { def cmds = [] cmds << zwave.switchBinaryV1.switchBinaryGet().format() cmds << zwave.configurationV2.configurationGet(parameterNumber: 3).format() cmds << zwave.configurationV2.configurationGet(parameterNumber: 4).format() cmds << zwave.associationV2.associationGet(groupingIdentifier: 3).format() if (getDataValue("MSR") == null) { cmds << zwave.manufacturerSpecificV1.manufacturerSpecificGet().format() } delayBetween(cmds,500) } def initialize() { sendEvent(name: "numberOfButtons", value: 1, displayed: false) sendEvent(name: "supportedButtonValues", value:JsonOutput.toJson(["up_2x","down_2x"]), displayed:false) } // Private Methods private parseAssocGroupList(list, group) { def nodes = group == 2 ? [] : [zwaveHubNodeId] if (list) { def nodeList = list.split(',') def max = group == 2 ? 5 : 4 def count = 0 nodeList.each { node -> node = node.trim() if ( count >= max) { log.warn "Association Group ${group}: Number of members is greater than ${max}! The following member was discarded: ${node}" } else if (node.matches("\\p{XDigit}+")) { def nodeId = Integer.parseInt(node,16) if (nodeId == zwaveHubNodeId) { log.warn "Association Group ${group}: Adding the hub as an association is not allowed (it would break double-tap)." } else if ( (nodeId > 0) & (nodeId < 256) ) { nodes << nodeId count++ } else { log.warn "Association Group ${group}: Invalid member: ${node}" } } else { log.warn "Association Group ${group}: Invalid member: ${node}" } } } return nodes }