diff --git a/devicetypes/erocm123/sonoff-wifi-switch-2-0.src/sonoff-wifi-switch-2-0.groovy b/devicetypes/erocm123/sonoff-wifi-switch-2-0.src/sonoff-wifi-switch-2-0.groovy new file mode 100644 index 0000000..49c0da8 --- /dev/null +++ b/devicetypes/erocm123/sonoff-wifi-switch-2-0.src/sonoff-wifi-switch-2-0.groovy @@ -0,0 +1,320 @@ +/** + * Copyright 2016 Eric Maycock + * + * 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. + * + * Sonoff Wifi Switch 2.0 + * + * Author: Eric Maycock (erocm123) + * Date: 2016-01-27 + */ + +import groovy.json.JsonSlurper +import groovy.util.XmlSlurper + +metadata { + definition (name: "Sonoff Wifi Switch 2.0", namespace: "erocm123", author: "Eric Maycock") { + capability "Actuator" + capability "Switch" + capability "Refresh" + capability "Sensor" + capability "Configuration" + + command "reboot" + } + + simulator { + } + + preferences { + input("ip", "string", title:"IP Address", description: "192.168.1.150" ,required: true, displayDuringSetup: true) + // Port should always be 80 + //input("port", "string", title:"Port", description: "80" , required: true, displayDuringSetup: true) + } + + tiles (scale: 2){ + multiAttributeTile(name:"switch", type: "lighting", width: 6, height: 4, canChangeIcon: true){ + tileAttribute ("device.switch", key: "PRIMARY_CONTROL") { + attributeState "on", label:'${name}', action:"switch.off", icon:"st.lights.philips.hue-single", backgroundColor:"#79b821", nextState:"turningOff" + attributeState "off", label:'${name}', action:"switch.on", icon:"st.lights.philips.hue-single", backgroundColor:"#ffffff", nextState:"turningOn" + attributeState "turningOn", label:'${name}', action:"switch.off", icon:"st.lights.philips.hue-single", backgroundColor:"#79b821", nextState:"turningOff" + attributeState "turningOff", label:'${name}', action:"switch.on", icon:"st.lights.philips.hue-single", backgroundColor:"#ffffff", nextState:"turningOn" + } + } + + standardTile("refresh", "device.switch", inactiveLabel: false, decoration: "flat", width: 2, height: 2) { + state "default", label:"", action:"refresh.refresh", icon:"st.secondary.refresh" + } + standardTile("configure", "device.configure", inactiveLabel: false, width: 2, height: 2, decoration: "flat") { + state "configure", label:'', action:"configuration.configure", icon:"st.secondary.configure" + } + valueTile("reboot", "device.reboot", decoration: "flat", height: 2, width: 2, inactiveLabel: false, canChangeIcon: false) { + state "default", label:"Reboot", action:"reboot", icon:"", backgroundColor:"#FFFFFF" + } + valueTile("hubInfo", "device.hubInfo", decoration: "flat", height: 2, width: 6, inactiveLabel: false, canChangeIcon: false) { + state "hubInfo", label:'${currentValue}' //backgroundColor:"#FFFFFF" + } + + } + + main(["switch"]) + details(["switch", + "refresh","configure","reboot", + "hubInfo"]) +} + +def installed() { + log.debug "installed()" + configure() +} + +def updated() { + log.debug "updated()" + configure() +} + +def configure() { + log.debug "configure()" + log.debug "Configuring Device For SmartThings Use" + state.ruleConfigured = false + state.switchConfigured = false + state.buttonConfigured = false + sendEvent(name:"hubInfo", value:"Sonoff switch still being configured") + if (state.MAC != null) state.dni = setDeviceNetworkId(state.MAC) + else + if (ip != null) state.dni = setDeviceNetworkId(ip, "80") + state.hubIP = device.hub.getDataValue("localIP") + response(setupDevices() + setupRules() + refresh()) +} + +def setupDevices() { + def cmds = [] + cmds << postAction("/devices?index=1&page=1", "taskdevicenumber=1&edit=1&page=1") + cmds << postAction("/devices?index=1&page=1", "taskdevicenumber=1&taskdevicename=BUTTON&taskdevicetimer=0&taskdeviceid=1&taskdevicepin1=0&taskdevicepin1pullup=on&plugin_001_type=1&plugin_001_button=2&taskdevicevaluename1=Switch&edit=1&page=1") + cmds << postAction("/devices?index=2&page=1", "taskdevicenumber=1&edit=1&page=1") + cmds << postAction("/devices?index=2&page=1", "taskdevicenumber=1&taskdevicename=SWITCH&taskdevicetimer=0&taskdeviceid=2&taskdevicepin1=12&taskdevicepin1pullup=on&plugin_001_type=1&plugin_001_button=0&taskdevicesenddata=on&taskdevicevaluename1=Switch&edit=1&page=1") + return delayBetween(cmds, 1000) +} + +def setupRules() { + def cmds = [] + cmds << postAction("/advanced", "mqttsubscribe=&mqttpublish=&messagedelay=1000&ip=0&ntphost=&timezone=0&syslogip=0.0.0.0&sysloglevel=0&udpport=0&useserial=on&serialloglevel=0&webloglevel=0&baudrate=115200&wdi2caddress=0&wireclockstretchlimit=0&userules=on&edit=1") + cmds << postAction("/rules", "rules=On+BUTTON%23Switch+do%0D%0A++if+%5BSWITCH%23Switch%5D%3D0%0D%0A++++gpio%2C12%2C1%0D%0A++else%0D%0A++++gpio%2C12%2C0%0D%0A++endif%0D%0Aendon%0D%0A%0D%0AOn+SWITCH%23Switch+do%0D%0A+if+%5BSWITCH%23Switch%5D%3D1%0D%0A++++gpio%2C13%2C0%0D%0A++else%0D%0A++++gpio%2C13%2C1%0D%0A++endif%0D%0Aendon") + return delayBetween(cmds, 1000) +} + +def setupConfig() { + // Automatic config page submit not working at this time. Has to be done manually + /*def hubIP = device.hub.getDataValue("localIP") + log.debug "Hub IP: ${hubIP}" + def cmds = [] + //cmds << postAction("/config", "protocol=1&usedns=0&controllerip=$hubIP&controllerport=39500&controlleruser=&controllerpassword=&delay=60") + return delayBetween(cmds, 1000)*/ +} + +def parse(description) { + //log.debug "Parsing: ${description}" + def events = [] + def cmds + def descMap = parseDescriptionAsMap(description) + def body + log.debug "descMap: ${descMap}" + + + if (!state.MAC || state.MAC != descMap["mac"]) { + log.debug "Mac address of device found ${descMap["mac"]}" + updateDataValue("MAC", descMap["mac"]) + } + + if (state.MAC != null && state.dni != state.MAC) state.dni = setDeviceNetworkId(state.MAC) + if (descMap["body"]) body = new String(descMap["body"].decodeBase64()) + + if (body && body != "") { + + + if(body.startsWith("{") || body.startsWith("[")) { + def slurper = new JsonSlurper() + def result = slurper.parseText(body) + + //log.debug "result: ${result}" + + if (result.containsKey("Sensors")) { + def mySwitch = result.Sensors.find { it.TaskName == "SWITCH" } + def myButton = result.Sensors.find { it.TaskName == "BUTTON" } + def myLED = result.Sensors.find { it.TaskName == "LED" } + if (mySwitch) { + events << createEvent(name:"switch", value: (mySwitch.Switch.toInteger() == 0 ? 'off' : 'on')) + state.switchConfigured = true + } + if (myButton) state.buttonConfigured = true + //if (myLED) log.debug "LED is ${(myLED.Switch.toInteger() == 0 ? 'off' : 'on')}" + } + if (result.containsKey("pin")) { + if (result.pin == 12) events << createEvent(name:"switch", value: (result.state.toInteger() == 0 ? 'off' : 'on')) + } + + if (result.containsKey("System")) { + if (result.System.containsKey("Uptime")) log.debug "System has been up ${result.System.Uptime.toInteger() / 60} hours" + } + } else { + //log.debug "Response is not JSON: $body" + def ruleSearch = "OnBUTTONSwitchdoifSWITCHSwitch0gpio121elsegpio120endifendonOnSWITCHSwitchdoifSWITCHSwitch1gpio130elsegpio131endifendon" + if (body.replaceAll("\\W", "").indexOf(ruleSearch) > 0) state.ruleConfigured = true + + } + } else { + cmds = refresh() + } + + if (settings.ip) { + //log.debug "switch: $state.switchConfigured, button: $state.buttonConfigured, rule: $state.ruleConfigured" + if (state.switchConfigured == true && state.buttonConfigured == true && state.ruleConfigured == true) { + events << createEvent(name:"hubInfo", value:"For instant status updates, configure switch at http://$settings.ip/config | Hub Info - IP: ${device.hub.getDataValue("localIP")}, Port: 39500") + } else { + events << createEvent(name:"hubInfo", value:"Sonoff switch still being configured") + } + } + else { + events << createEvent(name:"hubInfo", value:"IP address of the switch not entered. Please do so in device preferences.") + } + + if (cmds) return cmds else return events + +} + +def parseDescriptionAsMap(description) { + description.split(",").inject([:]) { map, param -> + def nameAndValue = param.split(":") + + if (nameAndValue.length == 2) map += [(nameAndValue[0].trim()):nameAndValue[1].trim()] + else map += [(nameAndValue[0].trim()):""] + } +} + +private parseHTML(html) { + //log.debug html + def myHtml = html.split("<") + def result = [] + switch (myHtml[0]) { + case "GPIO 12 Set to 0": + result = [name: "switch", value: "off"] + break + case "GPIO 12 Set to 1": + result = [name: "switch", value: "on"] + break + case "GPIO 13 Set to 0": + log.debug "LED is on" + break + case "GPIO 13 Set to 1": + log.debug "LED is off" + break + default: + break + } + return result +} + +private ledOn() { + return getAction("/control?cmd=GPIO,13,0") +} + +private ledOff() { + return getAction("/control?cmd=GPIO,13,1") +} + +def on() { + log.debug "on()" + def cmds = [] + cmds << getAction("/control?cmd=GPIO,12,1") + return cmds +} + +def off() { + log.debug "off()" + def cmds = [] + cmds << getAction("/control?cmd=GPIO,12,0") + return cmds +} + +def refresh() { + log.debug "refresh()" + def cmds = [] + cmds << getAction("/json") + return cmds +} + +private getAction(uri){ + updateDNI() + def headers = getHeader() + def hubAction = new physicalgraph.device.HubAction( + method: "GET", + path: uri, + headers: headers + ) + return hubAction +} + +private postAction(uri, data){ + updateDNI() + def headers = getHeader() + def hubAction = new physicalgraph.device.HubAction( + method: "POST", + path: uri, + headers: headers, + body: data + ) + return hubAction +} + +private setDeviceNetworkId(ip, port = null){ + def myDNI + if (port == null) { + myDNI = ip + } else { + def iphex = convertIPtoHex(ip) + def porthex = convertPortToHex(port) + myDNI = "$iphex:$porthex" + } + log.debug "Device Network Id set to ${myDNI}" + return myDNI +} + +private updateDNI() { + if (device.deviceNetworkId != state.dni) { + device.deviceNetworkId = state.dni + } +} + +private getHostAddress() { + return "${ip}:80" +} + +private String convertIPtoHex(ipAddress) { + String hex = ipAddress.tokenize( '.' ).collect { String.format( '%02x', it.toInteger() ) }.join() + return hex +} + +private String convertPortToHex(port) { + String hexport = port.toString().format( '%04x', port.toInteger() ) + return hexport +} + +private getHeader(){ + def headers = [:] + headers.put("Host", getHostAddress()) + headers.put("Content-Type", "application/x-www-form-urlencoded") + return headers +} + +def reboot() { + log.debug "reboot()" + def uri = "/?cmd=reboot" + getAction(uri) +} \ No newline at end of file diff --git a/smartapps/erocm123/sonoff-connect.src/sonoff-connect.groovy b/smartapps/erocm123/sonoff-connect.src/sonoff-connect.groovy new file mode 100644 index 0000000..c63caac --- /dev/null +++ b/smartapps/erocm123/sonoff-connect.src/sonoff-connect.groovy @@ -0,0 +1,348 @@ +/** + * Copyright 2016 Eric Maycock + * + * 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. + * + * Sonoff (Connect) + * + * Author: Eric Maycock (erocm123) + * Date: 2016-06-02 + */ + +definition( + name: "Sonoff (Connect)", + namespace: "erocm123", + author: "Eric Maycock (erocm123)", + description: "Service Manager for Sonoff switches", + category: "Convenience", + iconUrl: "https://raw.githubusercontent.com/erocm123/SmartThingsPublic/master/smartapps/erocm123/sonoff-connect.src/sonoff-connect-icon.png", + iconX2Url: "https://raw.githubusercontent.com/erocm123/SmartThingsPublic/master/smartapps/erocm123/sonoff-connect.src/sonoff-connect-icon-2x.png", + iconX3Url: "https://raw.githubusercontent.com/erocm123/SmartThingsPublic/master/smartapps/erocm123/sonoff-connect.src/sonoff-connect-icon-3x.png" +) + +preferences { + page(name: "mainPage") + page(name: "configurePDevice") + page(name: "deletePDevice") + page(name: "changeName") + page(name: "discoveryPage", title: "Device Discovery", content: "discoveryPage", refreshTimeout:5) + page(name: "addDevices", title: "Add Sonoff Switches", content: "addDevices") + page(name: "deviceDiscovery") +} + +def mainPage() { + dynamicPage(name: "mainPage", title: "Manage your Sonoff switches", nextPage: null, uninstall: true, install: true) { + section("Configure"){ + href "deviceDiscovery", title:"Discover Sonoff Devices", description:"" + } + section("Installed Devices"){ + getChildDevices().sort({ a, b -> a["deviceNetworkId"] <=> b["deviceNetworkId"] }).each { + href "configurePDevice", title:"$it.label", description:"", params: [did: it.deviceNetworkId] + } + } + } +} + +def configurePDevice(params){ + def currentDevice + getChildDevices().each { + if(it.deviceNetworkId == params.did){ + state.currentDeviceId = it.deviceNetworkId + state.currentDisplayName = it.displayName + } + } + dynamicPage(name: "configurePDevice", title: "Configure Sonoff Switches created with this app", nextPage: null) { + section { + app.updateSetting("${state.currentDeviceId}_label", getChildDevice(state.currentDeviceId).label) + input "${state.currentDeviceId}_label", "text", title:"Device Name", description: "", required: false + href "changeName", title:"Change Device Name", description: "Edit the name above and click here to change it", params: [did: state.currentDeviceId] + } + section { + href "deletePDevice", title:"Delete $state.currentDisplayName", description: "", params: [did: state.currentDeviceId] + } + } +} + +def deletePDevice(params){ + try { + unsubscribe() + getChildDevices().each { + if(it.deviceNetworkId.startsWith("${params.did}/")) deleteChildDevice(it.deviceNetworkId) + } + deleteChildDevice(params.did) + dynamicPage(name: "deletePDevice", title: "Deletion Summary", nextPage: "mainPage") { + section { + paragraph "The device has been deleted. Press next to continue" + } + } + } catch (e) { + dynamicPage(name: "deletePDevice", title: "Deletion Summary", nextPage: "mainPage") { + section { + paragraph "Error: ${(e as String).split(":")[1]}." + } + } + } +} + +def changeName(params){ + def thisDevice = getChildDevice(params.did) + thisDevice.label = settings["${params.did}_label"] + + dynamicPage(name: "changeName", title: "Change Name Summary", nextPage: "mainPage") { + section { + paragraph "The device has been renamed. Press \"Next\" to continue" + } + } +} + +def discoveryPage(){ + return deviceDiscovery() +} + +def deviceDiscovery(params=[:]) +{ + def devices = devicesDiscovered() + + int deviceRefreshCount = !state.deviceRefreshCount ? 0 : state.deviceRefreshCount as int + state.deviceRefreshCount = deviceRefreshCount + 1 + def refreshInterval = 3 + + def options = devices ?: [] + def numFound = options.size() ?: 0 + + if ((numFound == 0 && state.deviceRefreshCount > 25) || params.reset == "true") { + log.trace "Cleaning old device memory" + state.devices = [:] + state.deviceRefreshCount = 0 + app.updateSetting("selectedDevice", "") + } + + ssdpSubscribe() + + //sonoff discovery request every 15 //25 seconds + if((deviceRefreshCount % 5) == 0) { + discoverDevices() + } + + //setup.xml request every 3 seconds except on discoveries + if(((deviceRefreshCount % 3) == 0) && ((deviceRefreshCount % 5) != 0)) { + verifyDevices() + } + + return dynamicPage(name:"deviceDiscovery", title:"Discovery Started!", nextPage:"addDevices", refreshInterval:refreshInterval, uninstall: true) { + section("Please wait while we discover your Sonoff devices. Discovery can take five minutes or more, so sit back and relax! Select your device below once discovered.") { + input "selectedDevices", "enum", required:false, title:"Select Sonoff Switch (${numFound} found)", multiple:true, options:options + } + section("Options") { + href "deviceDiscovery", title:"Reset list of discovered devices", description:"", params: ["reset": "true"] + } + } +} + +Map devicesDiscovered() { + def vdevices = getVerifiedDevices() + def map = [:] + vdevices.each { + def value = "${it.value.name}" + def key = "${it.value.mac}" + map["${key}"] = value + } + map +} + +def getVerifiedDevices() { + getDevices().findAll{ it?.value?.verified == true } +} + +private discoverDevices() { + sendHubCommand(new physicalgraph.device.HubAction("lan discovery urn:schemas-upnp-org:device:Basic:1", physicalgraph.device.Protocol.LAN)) +} + +def configured() { + +} + +def buttonConfigured(idx) { + return settings["lights_$idx"] +} + +def isConfigured(){ + if(getChildDevices().size() > 0) return true else return false +} + +def isVirtualConfigured(did){ + def foundDevice = false + getChildDevices().each { + if(it.deviceNetworkId != null){ + if(it.deviceNetworkId.startsWith("${did}/")) foundDevice = true + } + } + return foundDevice +} + +private virtualCreated(number) { + if (getChildDevice(getDeviceID(number))) { + return true + } else { + return false + } +} + +private getDeviceID(number) { + return "${state.currentDeviceId}/${app.id}/${number}" +} + +def installed() { + initialize() +} + +def updated() { + unsubscribe() + unschedule() + initialize() +} + +def initialize() { + ssdpSubscribe() + runEvery5Minutes("ssdpDiscover") +} + +void ssdpSubscribe() { + subscribe(location, "ssdpTerm.urn:schemas-upnp-org:device:Basic:1", ssdpHandler) +} + +void ssdpDiscover() { + sendHubCommand(new physicalgraph.device.HubAction("lan discovery urn:schemas-upnp-org:device:Basic:1", physicalgraph.device.Protocol.LAN)) +} + +def ssdpHandler(evt) { + def description = evt.description + def hub = evt?.hubId + def parsedEvent = parseLanMessage(description) + parsedEvent << ["hub":hub] + + def devices = getDevices() + + String ssdpUSN = parsedEvent.ssdpUSN.toString() + + if (devices."${ssdpUSN}") { + def d = devices."${ssdpUSN}" + def child = getChildDevice(parsedEvent.mac) + def childIP + def childPort + if (child) { + childIP = child.getDeviceDataByName("ip") + childPort = child.getDeviceDataByName("port").toString() + log.debug "Device data: ($childIP:$childPort) - reporting data: (${convertHexToIP(parsedEvent.networkAddress)}:${convertHexToInt(parsedEvent.deviceAddress)})." + if(childIP != convertHexToIP(parsedEvent.networkAddress) || childPort != convertHexToInt(parsedEvent.deviceAddress).toString()){ + log.debug "Device data (${child.getDeviceDataByName("ip")}) does not match what it is reporting(${convertHexToIP(parsedEvent.networkAddress)}). Attempting to update." + child.sync(convertHexToIP(parsedEvent.networkAddress), convertHexToInt(parsedEvent.deviceAddress).toString()) + } + } + + if (d.networkAddress != parsedEvent.networkAddress || d.deviceAddress != parsedEvent.deviceAddress) { + d.networkAddress = parsedEvent.networkAddress + d.deviceAddress = parsedEvent.deviceAddress + } + } else { + devices << ["${ssdpUSN}": parsedEvent] + } +} + +void verifyDevices() { + def devices = getDevices().findAll { it?.value?.verified != true } + devices.each { + def ip = convertHexToIP(it.value.networkAddress) + def port = convertHexToInt(it.value.deviceAddress) + String host = "${ip}:${port}" + sendHubCommand(new physicalgraph.device.HubAction("""GET ${it.value.ssdpPath} HTTP/1.1\r\nHOST: $host\r\n\r\n""", physicalgraph.device.Protocol.LAN, host, [callback: deviceDescriptionHandler])) + } +} + +def getDevices() { + state.devices = state.devices ?: [:] +} + +void deviceDescriptionHandler(physicalgraph.device.HubResponse hubResponse) { + log.trace "description.xml response (application/xml)" + def body = hubResponse.xml + if (body?.device?.modelName?.text().startsWith("Sonoff Wifi Switch")) { + def devices = getDevices() + def device = devices.find {it?.key?.contains(body?.device?.UDN?.text())} + if (device) { + device.value << [name:body?.device?.friendlyName?.text() + " (" + convertHexToIP(hubResponse.ip) + ")", serialNumber:body?.device?.serialNumber?.text(), verified: true] + } else { + log.error "/description.xml returned a device that didn't exist" + } + } +} + +def addDevices() { + def devices = getDevices() + def sectionText = "" + + selectedDevices.each { dni ->bridgeLinking + def selectedDevice = devices.find { it.value.mac == dni } + def d + if (selectedDevice) { + d = getChildDevices()?.find { + it.deviceNetworkId == selectedDevice.value.mac + } + } + + if (!d) { + log.debug "Creating Sonoff Switch with dni: ${selectedDevice.value.mac}" + log.debug Integer.parseInt(selectedDevice.value.deviceAddress,16) + addChildDevice("erocm123", "Sonoff Wifi Switch", selectedDevice.value.mac, selectedDevice?.value.hub, [ + "label": selectedDevice?.value?.name ?: "Sonoff Wifi Switch", + "data": [ + "mac": selectedDevice.value.mac, + "ip": convertHexToIP(selectedDevice.value.networkAddress), + "port": "" + Integer.parseInt(selectedDevice.value.deviceAddress,16) + ] + ]) + sectionText = sectionText + "Succesfully added Sonoff Wifi Switch with ip address ${convertHexToIP(selectedDevice.value.networkAddress)} \r\n" + } + + } + log.debug sectionText + return dynamicPage(name:"addDevices", title:"Devices Added", nextPage:"mainPage", uninstall: true) { + if(sectionText != ""){ + section("Add Sonoff Results:") { + paragraph sectionText + } + }else{ + section("No devices added") { + paragraph "All selected devices have previously been added" + } + } +} + } + +def uninstalled() { + unsubscribe() + getChildDevices().each { + deleteChildDevice(it.deviceNetworkId) + } +} + + + +private String convertHexToIP(hex) { + [convertHexToInt(hex[0..1]),convertHexToInt(hex[2..3]),convertHexToInt(hex[4..5]),convertHexToInt(hex[6..7])].join(".") +} + +private Integer convertHexToInt(hex) { + Integer.parseInt(hex,16) +} + +def log(message){ + +} \ No newline at end of file