/**
* WeMo Connect
*
* Author: Jason Cheatham
* Last updated: 2021-03-21, 17:07:01-0400
*
* Based on the original Wemo (Connect) Advanced app by SmartThings, updated by
* superuser-ule 2016-02-24
*
* Original Copyright 2015 SmartThings
*
* 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.
*/
definition(
name: 'WeMo Connect',
namespace: 'jason0x43',
author: 'Jason Cheatham',
description: 'Allows you to integrate your WeMo devices with Hubitat.',
singleInstance: true,
iconUrl: 'https://s3.amazonaws.com/smartapp-icons/Partner/wemo.png',
iconX2Url: 'https://s3.amazonaws.com/smartapp-icons/Partner/wemo@2x.png',
importUrl: 'https://raw.githubusercontent.com/jason0x43/hubitat/master/apps/jason0x43-wemo_connect.groovy'
)
preferences {
page(name: 'mainPage')
}
import hubitat.helper.HexUtils
def mainPage() {
debugLog("mainPage: Rendering main with state: ${state}")
// Reset the refresh state if the last refresh was more than 60 seconds ago
if (
!state.refreshCount
|| !state.lastRefresh
|| (now() - state.lastRefresh) > 60000
) {
debugLog("mainPage: Resetting refresh count and discovered devices")
state.refreshCount = 0
state.discoveredDevices = [:]
}
state.minDriverVersion = 4
def refreshCount = state.refreshCount
state.refreshCount = refreshCount + 1
state.lastRefresh = now()
// ssdp request every 30 seconds
discoverAllWemoTypes()
def devices = getKnownDevices()
debugLog("mainPage: Known devices: ${devices}")
def deviceLabels = [:]
devices.each { mac, data -> deviceLabels[mac] = data.label }
dynamicPage(
name: 'mainPage',
install: true,
uninstall: true,
refreshInterval: 30
) {
section('
Device Discovery
') {
paragraph(
'Device discovery messages are being broadcast every 30 ' +
'seconds. Any devices on the local network should show up ' +
'within a minute or two.'
)
input(
'selectedDevices',
'enum',
required: false,
title: "Select Wemo Devices \n(${devices.size() ?: 0} found)",
multiple: true,
options: deviceLabels
)
}
section('Options
') {
input(
'interval',
'number',
title: 'How often should WeMo devices be refreshed? ' +
'(minutes, default is 5, max is 59)',
defaultValue: 5
)
input(
'debugLogging',
'bool',
title: 'Enable debug logging',
defaultValue: false,
submitOnChange: true
)
}
}
}
def installed() {
log.info('Installed')
initialize()
}
def updated() {
log.info('Updated')
initialize()
}
def uninstalled() {
log.info('Uninstalling')
// Remove any child devices created by this app
getChildDevices().each { device ->
log.info("Removing child device ${device}")
deleteChildDevice(device.deviceNetworkId)
}
}
def initialize() {
log.info('Initializing')
unschedule()
if (selectedDevices) {
initDevices()
}
def interval = Math.min(settings.interval ?: 5, 59)
// cron fields:
// seconds
// minutes
// hours
// day of month
// month
// day of week
// year
debugLog("initialize: scheduling discovery for every ${interval} minutes")
schedule("0 0/${interval} * * * ?", refreshDevices)
}
def refreshDevices() {
log.info('Refreshing Wemo devices')
getChildDevices().each { device -> device.refresh() }
discoverAllWemoTypes()
}
def childGetHostAddress(device) {
debugLog("childGetHostAddress: getting address for ${device}")
def hexIp = device.getDataValue('ip')
def hexPort = device.getDataValue('port')
debugLog("childGetHostAddress: hexIp = ${hexIp}")
debugLog("childGetHostAddress: hexPort = ${hexPort}")
try {
return toDecimalAddress("${hexIp}:${hexPort}")
} catch (Throwable t) {
info.warn("Error parsing child address: $t");
return null
}
}
def childGetBinaryState(child) {
log.info("Getting state for ${child}")
debugLog(
"childGetBinaryState: sending request to ${childGetHostAddress(child)}"
)
new hubitat.device.HubSoapAction(
path: '/upnp/control/basicevent1',
urn: 'urn:Belkin:service:basicevent:1',
action: 'GetBinaryState',
headers: [
HOST: childGetHostAddress(child)
]
)
}
def childResubscribe(child) {
log.info("Resubscribing ${child}")
def sid = child.getDataValue('subscriptionId')
if (sid == null) {
debugLog(
"childResubscribe: No existing subscription for ${child} -- " +
"subscribing"
)
return childSubscribe(child)
}
debugLog("childResubscribe: renewing ${child} subscription to ${sid}")
// Clear the existing SID -- it should be set if the resubscribe succeeds
debugLog('childReubscribe: clearing existing sid')
child.updateDataValue('subscriptionId', null)
debugLog(
"childResubscribe: sending request to ${childGetHostAddress(child)}"
)
new hubitat.device.HubAction([
method: 'SUBSCRIBE',
path: '/upnp/event/basicevent1',
headers: [
HOST: childGetHostAddress(child),
TIMEOUT: "Second-${getSubscriptionTimeout()}",
SID: "uuid:${sid}"
]
], child.deviceNetworkId)
}
def childSetBinaryState(child, state, brightness = null) {
log.info("Setting binary state for ${child}")
def body = [ BinaryState: "$state" ]
if (brightness != null) {
body.brightness = "$brightness"
}
debugLog(
"childSetBinaryState: sending binary state request to " +
"${childGetHostAddress(child)}"
)
new hubitat.device.HubSoapAction(
path: '/upnp/control/basicevent1',
urn: 'urn:Belkin:service:basicevent:1',
action: 'SetBinaryState',
body: body,
headers: [
Host: childGetHostAddress(child)
]
)
}
def childSubscribe(child) {
log.info("Subscribing to events for ${child}")
// Clear out any current subscription ID; will be reset when the
// subscription completes
debugLog('childSubscribe: clearing existing sid')
child.updateDataValue('subscriptionId', '')
debugLog(
"childSubscribe: sending subscribe request to " +
"${childGetHostAddress(child)}"
)
new hubitat.device.HubAction([
method: 'SUBSCRIBE',
path: '/upnp/event/basicevent1',
headers: [
HOST: childGetHostAddress(child),
CALLBACK: "",
NT: 'upnp:event',
TIMEOUT: "Second-${getSubscriptionTimeout()}"
]
], child.deviceNetworkId)
}
def childSubscribeIfNecessary(child) {
debugLog("childSubscribeIfNecessary: checking subscription for ${child}")
def sid = child.getDataValue('subscriptionId')
if (sid == null) {
debugLog(
"childSubscribeIfNecessary: no active subscription -- subscribing"
)
childSubscribe(child)
} else {
debugLog("childSubscribeIfNecessary: active subscription -- skipping")
}
}
def childSyncTime(child) {
debugLog("childSyncTime: requesting sync for ${child}")
def now = new Date();
def tz = location.timeZone;
def offset = tz.getOffset(now.getTime())
def offsetHours = (offset / 1000 / 60 / 60).intValue()
def tzOffset = (offsetHours < 0 ? '-' : '') +
String.format('%02d.00', Math.abs(offsetHours))
def isDst = tz.inDaylightTime(now)
def hasDst = tz.observesDaylightTime()
debugLog(
"childSyncTime: sending sync request to ${childGetHostAddress(child)}"
)
new hubitat.device.HubSoapAction(
path: '/upnp/control/timesync1',
url: 'urn:Belkin:service:timesync:1',
action: 'TimeSync',
body: [
UTC: getTime(),
TimeZone: tzOffset,
dst: isDst ? 1 : 0,
DstSupported: hasDst ? 1 : 0
],
headers: [
HOST: childGetHostAddress(child)
]
)
}
def childUnsubscribe(child) {
debugLog("childUnsubscribe: unsubscribing ${child}")
def sid = child.getDataValue('subscriptionId')
// Clear out the current subscription ID
debugLog('childUnsubscribe: clearing existing sid')
child.updateDataValue('subscriptionId', '')
debugLog(
"childUnsubscribe: sending unsubscribe request to " +
"${childGetHostAddress(child)}"
)
new hubitat.device.HubAction([
method: 'UNSUBSCRIBE',
path: '/upnp/event/basicevent1',
headers: [
HOST: childGetHostAddress(child),
SID: "uuid:${sid}"
]
], child.deviceNetworkId)
}
def childUpdateSubscription(message, child) {
def headerString = message.header
if (isSubscriptionHeader(headerString)) {
def sid = getSubscriptionId(headerString)
debugLog(
"childUpdateSubscription: updating subscriptionId for ${child} " +
"to ${sid}"
)
child.updateDataValue('subscriptionId', sid)
}
}
def getSubscriptionId(header) {
def sid = (header =~ /SID: uuid:.*/) ?
(header =~ /SID: uuid:.*/)[0] :
'0'
sid -= 'SID: uuid:'.trim()
return sid;
}
def getSubscriptionTimeout() {
return 60 * (settings.interval ?: 5)
}
def getTime() {
// This is essentially System.currentTimeMillis()/1000, but System is
// disallowed by the sandbox.
((new GregorianCalendar().time.time / 1000l).toInteger()).toString()
}
/**
* Handle the setup.xml data for a device
*
* The device descriptor in body.device should match up, more or less, with
* the device descriptor returned by parseDiscoveryMessage.
*/
def handleSetupXml(response) {
def body = response.xml
def device = body.device
def deviceType = "${device.deviceType}"
def friendlyName = "${device.friendlyName}"
debugLog(
"handleSetupXml: Handling setup.xml for ${deviceType}" +
" (friendly name is '${friendlyName}')"
)
if (
deviceType.startsWith('urn:Belkin:device:controllee:1') ||
deviceType.startsWith('urn:Belkin:device:insight:1') ||
deviceType.startsWith('urn:Belkin:device:Maker:1') ||
deviceType.startsWith('urn:Belkin:device:sensor') ||
deviceType.startsWith('urn:Belkin:device:lightswitch') ||
deviceType.startsWith('urn:Belkin:device:dimmer')
) {
def entry = getDiscoveredDevices().find {
it.key.contains("${device.UDN}")
}
if (entry) {
def dev = entry.value
debugLog("handleSetupXml: updating ${dev}")
dev.name = friendlyName
dev.verified = true
} else {
log.error("/setup.xml returned a wemo device that doesn't exist")
}
}
}
def handleSsdpEvent(evt) {
def description = evt.description
def hub = evt?.hubId
def parsedEvent = parseDiscoveryMessage(description)
parsedEvent << ['hub': hub]
debugLog("handleSsdpEvent: Parsed discovery message: ${parsedEvent}")
def usn = parsedEvent.ssdpUSN.toString()
def device = getDiscoveredDevice(usn)
if (device) {
debugLog("handleSsdpEvent: Found cached device data for ${usn}")
// Ensure the cached ip and port agree with what's in the discovery
// event
device.ip = parsedEvent.ip
device.port = parsedEvent.port
def child = getChildDevice(device.mac)
if (child != null) {
debugLog(
"handleSsdpEvent: Updating IP address for" +
" ${child} [${device.mac}]"
)
updateChildAddress(child, device.ip, device.port)
}
} else {
debugLog(
"handleSsdpEvent: Adding ${parsedEvent.mac} to list of" +
" known devices"
)
def id = parsedEvent.ssdpUSN.toString()
device = parsedEvent
state.discoveredDevices[id] = device
}
if (!device.verified) {
debugLog("handleSsdpEvent: Verifying ${device}")
getSetupXml("${device.ip}:${device.port}")
}
}
private hexToInt(hex) {
Integer.parseInt(hex, 16)
}
private hexToIp(hex) {
[
hexToInt(hex[0..1]),
hexToInt(hex[2..3]),
hexToInt(hex[4..5]),
hexToInt(hex[6..7])
].join('.')
}
private debugLog(message) {
if (settings.debugLogging) {
log.debug message
}
}
private discoverAllWemoTypes() {
def targets = [
'urn:Belkin:device:insight:1',
'urn:Belkin:device:Maker:1',
'urn:Belkin:device:controllee:1',
'urn:Belkin:device:sensor:1',
'urn:Belkin:device:lightswitch:1',
'urn:Belkin:device:dimmer:1'
]
def targetStr = "${targets}"
if (state.subscribed != targetStr) {
targets.each { target ->
subscribe(location, "ssdpTerm.${target}", handleSsdpEvent)
debugLog('discoverAllWemoTypes: subscribed to ' + target)
}
state.subscribed = targetStr
}
debugLog("discoverAllWemoTypes: Sending discovery message for ${targets}")
sendHubCommand(
new hubitat.device.HubAction(
"lan discovery ${targets.join('/')}",
hubitat.device.Protocol.LAN
)
)
}
private getCallbackAddress() {
def hub = location.hubs[0];
def localIp = hub.getDataValue('localIP')
def localPort = hub.getDataValue('localSrvPortTCP')
"${localIp}:${localPort}"
}
private getKnownDevices() {
debugLog('getKnownDevices: Creating list of known devices')
// Known devices are a combination of existing (child) devices and newly
// discovered devices.
def knownDevices = [:]
// First, populate the known devices list with existing devices
def existingDevices = getChildDevices()
existingDevices.each { device ->
def mac = device.deviceNetworkId
def name = device.label ?: device.name
knownDevices[mac] = [
mac: mac,
name: name,
ip: device.getDataValue('ip'),
port: device.getDataValue('port'),
typeName: device.typeName,
needsUpdate: device.getDriverVersion() < state.minDriverVersion
]
debugLog(
"getKnownDevices: Added already-installed device ${mac}:${name}"
)
}
// Next, populate the list with verified devices (those from which a
// setup.xml has been retrieved).
def verifiedDevices = getDiscoveredDevices(true)
debugLog("getKnownDevices: verified devices: ${verifiedDevices}")
verifiedDevices.each { key, device ->
def mac = device.mac
if (knownDevices.containsKey(mac)) {
def knownDevice = knownDevices[mac]
// If there's a verified device corresponding to an already-
// installed device, update the installed device's name based
// on the name of the verified device.
def name = device.name
if (name != null && name != knownDevice.name) {
knownDevice.name =
"${name} (installed as ${knownDevice.name})"
debugLog("getKnownDevices: Updated name for ${mac} to ${name}")
}
} else {
def name = device.name ?:
"WeMo device ${device.ssdpUSN.split(':')[1][-3..-1]}"
knownDevices[mac] = [
mac: mac,
name: name,
ip: device.ip,
port: device.port,
// The ssdpTerm and hub will be used if a new child device is
// created
ssdpTerm: device.ssdpTerm,
hub: device.hub
]
debugLog(
"getKnownDevices: Added discovered device ${knownDevices[mac]}"
)
}
}
debugLog("getKnownDevices: Known devices: ${knownDevices}")
knownDevices.each { mac, device ->
def address
try {
address = "${hexToIp(device.ip)}:${hexToInt(device.port)}"
} catch (Throwable t) {
address = ""
log.warn("Error parsing device address: $t");
}
def text = "${device.name} [MAC: ${mac}, IP: ${address}"
if (device.typeName) {
def needsUpdate = device.needsUpdate
? ' << Driver needs update >>'
: ''
text += ", Driver: ${device.typeName}] ${needsUpdate}"
} else {
text += ']'
}
knownDevices[mac].label = "${text}"
}
return knownDevices
}
private getSetupXml(hexIpAddress) {
def hostAddress
try {
hostAddress = toDecimalAddress(hexIpAddress)
} catch (Throwable t) {
log.warn("Error parsing address ${hexIpAddress}: $t")
return
}
debugLog("getSetupXml: requesting setup.xml from ${hostAddress}")
sendHubCommand(
new hubitat.device.HubAction(
[
method: 'GET',
path: '/setup.xml',
headers: [ HOST: hostAddress ],
],
null,
[ callback: handleSetupXml ]
)
)
}
private getDiscoveredDevices(isVerified = false) {
debugLog(
"getDiscoveredDevices: Getting discovered" +
"${isVerified ? ' and verified' : ''} devices"
)
if (!state.discoveredDevices) {
state.discoveredDevices = [:]
}
if (isVerified) {
debugLog(
"getDiscoveredDevices: Finding verified devices in " +
"${state.discoveredDevices}"
)
return state.discoveredDevices.findAll { it.value?.verified }
}
return state.discoveredDevices
}
private getDiscoveredDevice(usn) {
debugLog("getDiscoveredDevice: Getting discovered device with USN ${usn}")
return getDiscoveredDevices()[usn]
}
private initDevices() {
debugLog('initDevices: Initializing devices')
def knownDevices = getKnownDevices()
selectedDevices.each { dni ->
debugLog(
"initDevices: Looking for selected device ${dni} in known " +
"devices..."
)
def selectedDevice = knownDevices[dni]
if (selectedDevice) {
debugLog(
"initDevices: Found device; looking for existing child with " +
"dni ${dni}"
)
def child = getChildDevice(dni)
if (!child) {
def driverName
def namespace = 'jason0x43'
debugLog(
"initDevices: Creating WeMo device for ${selectedDevice}"
)
switch (selectedDevice.ssdpTerm) {
case ~/.*insight.*/:
driverName = 'Wemo Insight Switch'
break
// The Light Switch and Switch use the same driver
case ~/.*lightswitch.*/:
case ~/.*controllee.*/:
driverName = 'Wemo Switch'
break
case ~/.*sensor.*/:
driverName = 'Wemo Motion'
break
case ~/.*dimmer.*/:
driverName = 'Wemo Dimmer'
break
case ~/.*Maker.*/:
driverName = 'Wemo Maker'
break
}
if (driverName) {
child = addChildDevice(
namespace,
driverName,
selectedDevice.mac,
selectedDevice.hub,
[
'label': selectedDevice.name ?: 'Wemo Device',
'data': [
'mac': selectedDevice.mac,
'ip': selectedDevice.ip,
'port': selectedDevice.port
]
]
)
log.info(
"initDevices: Created ${child.displayName} with id: " +
"${child.id}, MAC: ${child.deviceNetworkId}"
)
} else {
log.warn("initDevices: No driver for ${selectedDevice})")
}
} else {
debugLog(
"initDevices: Updating IP address for ${child}"
)
updateChildAddress(
child,
selectedDevice.ip,
selectedDevice.port
)
}
if (child) {
debugLog('initDevices: Setting up device subscription...')
child.refresh()
}
} else {
log.warn(
"initDevices: Could not find device ${dni} in ${knownDevices}"
)
}
}
}
private isSubscriptionHeader(header) {
if (header == null) {
return false;
}
header.contains("SID: uuid:") && header.contains('TIMEOUT:');
}
/**
* Parse a discovery message, returning a device descriptor
*/
private parseDiscoveryMessage(description) {
def device = [:]
def parts = description.split(',')
debugLog("parseDiscoveryMessage: Parsing discovery message: $description")
parts.each { part ->
part = part.trim()
def valueStr;
switch (part) {
case { it .startsWith('devicetype:') }:
valueString = part.split(':')[1].trim()
device.deviceType = valueString
break
case { it.startsWith('mac:') }:
valueString = part.split(':')[1].trim()
if (valueString) {
device.mac = valueString
}
break
case { it.startsWith('networkAddress:') }:
valueString = part.split(':')[1].trim()
if (valueString) {
device.ip = valueString
}
break
case { it.startsWith('deviceAddress:') }:
valueString = part.split(':')[1].trim()
if (valueString) {
device.port = valueString
}
break
case { it.startsWith('ssdpPath:') }:
valueString = part.split(':')[1].trim()
if (valueString) {
device.ssdpPath = valueString
}
break
case { it.startsWith('ssdpUSN:') }:
part -= 'ssdpUSN:'
valueString = part.split('::')[0].trim()
if (valueString) {
device.ssdpUSN = valueString
}
break
case { it.startsWith('ssdpTerm:') }:
part -= 'ssdpTerm:'
valueString = part.trim()
if (valueString) {
device.ssdpTerm = valueString
}
break
case { it.startsWith('headers:') }:
part -= 'headers:'
valueString = part.trim()
if (valueString) {
device.headers = valueString
}
break
case { it.startsWith('body:') }:
part -= 'body:'
valueString = part.trim()
if (valueString) {
device.body = valueString
}
break
}
}
device
}
private toDecimalAddress(address) {
debugLog("toDecimalAddress: converting ${address}")
def parts = address.split(':')
ip = parts[0]
port = parts[1]
"${hexToIp(ip)}:${hexToInt(port)}"
}
private updateChildAddress(child, ip, port) {
debugLog(
"updateChildAddress: Updating address of ${child} to ${ip}:${port}"
)
def address = "${ip}:${port}"
def decimalAddress
try {
decimalAddress = toDecimalAddress(address)
} catch (Throwable t) {
log.warn("Error parsing address ${address}: $t")
return
}
log.info(
"Verifying that IP for ${child} is set to ${decimalAddress}"
)
def existingIp = child.getDataValue('ip')
if (ip && existingIp && ip != existingIp) {
try {
debugLog(
"childSync: Updating IP from ${hexToIp(existingIp)} to " +
"${hexToIp(ip)}"
)
} catch (Throwable t) {
log.warn("Error parsing addresses $existingIp, $ip: $t")
debugLog("childSync: Updating IP from ${existingIp} to ${ip}")
}
child.updateDataValue('ip', ip)
}
def existingPort = child.getDataValue('port')
if (port != null && existingPort != null && port != existingPort) {
try {
debugLog(
"childSync: Updating port from ${hexToInt(existingPort)} to " +
"${hexToInt(port)}"
)
} catch (Throwable t) {
log.warn("Error parsing ports $existingPort, $port: $t")
debugLog(
"childSync: Updating port from ${existingPort} to ${port}"
)
}
child.updateDataValue('port', port)
}
childSubscribe(child)
}