/* Kasa Local Integration Copyright Dave Gutheinz License Information: https://github.com/DaveGut/HubitatActive/blob/master/KasaDevices/License.md 6.5.1 Hot fix for loop in EM Month Stat Processing due to month = 1 Minor Changes: added help text, notification of update available, Ping Test, Camera discovery device testing Major Change: Use new Hubitat multi-IP communications for device communications where applicable. Function Changes: addDevices split to LAN, CLOUD and new Manual. New Function: Configure (configures app and new devices), Link to change details: https://github.com/DaveGut/HubitatActive/blob/master/KasaDevices/Change_Descriptions.pdf ===================================================================================================*/ def appVersion() { return "6.5.4" } import groovy.json.JsonSlurper // ===== Default comms timeout during execution. def commsTO() { return 5 } definition( name: "Kasa Integration", namespace: "davegut", author: "Dave Gutheinz", description: "Application to install TP-Link bulbs, plugs, and switches.", category: "Convenience", iconUrl: "", iconX2Url: "", singleInstance: true, documentationLink: "https://github.com/DaveGut/HubitatActive/blob/master/KasaDevices/README.md", importUrl: "https://raw.githubusercontent.com/DaveGut/HubitatActive/master/KasaDevices/Application/KasaIntegrationApp.groovy" ) preferences { page(name: "initInstance") page(name: "startPage") page(name: "lanAddDevicesPage") page(name: "manAddDevicesPage") page(name: "manAddStart") page(name: "cloudAddDevicesPage") page(name: "cloudAddStart") page(name: "addDevicesPage") page(name: "addDevStatus") page(name: "listDevices") page(name: "kasaAuthenticationPage") page(name: "startGetToken") page(name: "removeDevicesPage") page(name: "listDevicesByIp") page(name: "listDevicesByName") page(name: "commsTest") page(name: "commsTestDisplay") page(name: "dbReset") } def installed() { updated() } def updated() { logInfo("updated: Updating device configurations and (if cloud enabled) Kasa Token") unschedule() app?.updateSetting("appSetup", [type:"bool", value: false]) app?.updateSetting("utilities", [type:"bool", value: false]) app?.updateSetting("debugLog", [type:"bool", value: false]) app?.removeSetting("pingKasaDevices") app?.removeSetting("lanSegment") app?.removeSetting("devAddresses") app?.removeSetting("devPort") app?.removeSetting("installHelp") app?.removeSetting("missingDevHelp") failedDeviceHelp if (userName && userName != "") { schedule("0 30 2 ? * MON,WED,SAT", schedGetToken) } updateConfigurations(true) state.remove("lanTest") state.remove("addedDevices") state.remove("failedAdds") state.remove("listDevices") } def uninstalled() { getAllChildDevices().each { deleteChildDevice(it.deviceNetworkId) } } def initInstance() { logDebug("initInstance: Getting external data for the app.") if (!debugLog) { app.updateSetting("debugLog", false) } if (!state.devices) { state.devices = [:] } if (!lanSegment) { def hub = location.hubs[0] def hubIpArray = hub.localIP.split('\\.') def segments = [hubIpArray[0],hubIpArray[1],hubIpArray[2]].join(".") app?.updateSetting("lanSegment", [type:"string", value: segments]) } if (!ports) { app?.updateSetting("ports", [type:"string", value: "9999"]) } if (!hostLimits) { app?.updateSetting("hostLimits", [type:"string", value: "1, 254"]) } getManifestData() startPage() } def startPage() { logInfo("starting Kasa Integration") if (selectedRemoveDevices) { removeDevices() } if (selectedAddDevices) { addDevices() } if (debugLog) { runIn(1800, debugOff) } try { state.segArray = lanSegment.split('\\,') state.portArray = ports.split('\\,') def rangeArray = hostLimits.split('\\,') def array0 = rangeArray[0].toInteger() def array1 = array0 + 2 if (rangeArray.size() > 1) { array1 = rangeArray[1].toInteger() } state.hostArray = [array0, array1] } catch (e) { logWarn("startPage: Invalid entry for Lan Segements, Host Array Range, or Ports. Resetting to default!") def hub = location.hubs[0] def hubIpArray = hub.localIP.split('\\.') def segments = [hubIpArray[0],hubIpArray[1],hubIpArray[2]].join(".") app?.updateSetting("lanSegment", [type:"string", value: segments]) app?.updateSetting("ports", [type:"string", value: "9999"]) app?.updateSetting("hostLimits", [type:"string", value: "1, 254"]) } return dynamicPage(name:"startPage", title:"Kasa Local Hubitat Integration, Version ${appVersion()}" + "\n(Instructions available using ? at upper right corner.)", uninstall: true, install: true) { section() { if (state.updateAvailable) { paragraph "App update available. Manifest: ${state.manifestData}" } input "installHelp", "bool", title: "Device Installation Help", submitOnChange: true, defaultalue: false if (installHelp) { paragraph "" } paragraph "LAN Configuration: [LanSegments: ${state.segArray}, " + "Ports ${state.portArray}, hostRange: ${state.hostArray}]" input "appSetup", "bool", title: "Modify LAN Configuration", submitOnChange: true, defaultalue: false if (appSetup) { input "lanSegment", "string", title: "Lan Segments (ex: 192.168.50, 192,168.01)", submitOnChange: true input "hostLimits", "string", title: "Host Address Range (ex: 5, 100)", submitOnChange: true input "ports", "string", title: "Ports for Port Forwarding (ex: 9999, 8000)", submitOnChange: true } href "lanAddDevicesPage", title: "Scan LAN for Kasa devices and add", description: "Primary Method to discover and add devices." href "manAddDevicesPage", title: "Manually enter data then add Kasa devices", description: "For use if devices are missed by Scan LAN." href "cloudAddDevicesPage", title: "Get Kasa devices from the Kasa Cloud and add", description: "For use with devices that can't be controlled on LAN." paragraph " " href "removeDevicesPage", title: "Remove Kasa Devices", description: "Select to remove selected Kasa Device from Hubitat." paragraph " " input "utilities", "bool", title: "Application Utilities", submitOnChange: true, defaultalue: false if (utilities == true) { href "listDevicesByIp", title: "Test Device LAN Status and List Devices by IP Address", description: "Select to test devices and get list." href "listDevicesByName", title: "Test Device LAN Status and List Devices by Name", description: "Select to test devices and get list." href "commsTest", title: "IP Comms Ping Test Tool", description: "Select for Ping Test Page." paragraph "\nCaution. Use Reset DB only if directed." href "dbReset", title: "Reset the Device Database", description: "Select recreate database based on Lan Configuration (above)." paragraph " " } input "debugLog", "bool", title: "Enable debug logging for 30 minutes", submitOnChange: true, defaultValue: false } } } // ============================================ // ===== Add Devices ========================== // ============================================ def lanAddDevicesPage() { logInfo("lanAddDevicesPage") addDevicesPage("LAN") } def cloudAddDevicesPage() { logInfo("cloudAddDevicesPage") return dynamicPage (name: "cloudAddDevicesPage", title: "Get device data from Kasa Cloud, Version ${appVersion()}", nextPage: startPage, install: false) { def note = "Instructions: \n\ta.\tIf not already done, select 'Kasa " + "Login and Token Update. \n\tb.\tVerify the token is not null. " + "\n\tc.\tSelect 'Add Devices to the Device Array'." section("Enter Device IP and Port: ") { paragraph note href "kasaAuthenticationPage", title: "Kasa Login and Token Update", description: "Select to enter credentials and get token" paragraph "Current Kasa Token: = ${kasaToken}" href "cloudAddStart", title: "Add Devices to the Device Array", description: "Press to continue" href "startPage", title: "Exit without Updating", description: "Return to start page without attempting" } } } def cloudAddStart() { addDevicesPage("CLOUD") } def manAddDevicesPage() { logInfo("manAddDevicesPage") return dynamicPage (name: "manAddDevicesPage", title: "Manually add devices by IP, Version ${appVersion()}", nextPage: startPage, install: false) { def note = "Instructions: \n\ta.\tEnter the segment for you LAN.\n\tab.\t" + "Enter the device IP\n\tc.\tEnter the Port.\nThe system will attempt " + "to find the devices two times then display a selection menu with all uninstalled " + "devices; including, the device not previously discovered. If this " + "fails, check your local wifi configuration." section("Enter Device IP and Port: ") { def hub = location.hubs[0] def hubIpArray = hub.localIP.split('\\.') def segment = [hubIpArray[0],hubIpArray[1],hubIpArray[2]].join(".") paragraph note input ("lanSegment", "string", title: "Lan Segment (ex: 192.168.50)", defaultValue: segment, submitOnChange: true) input ("devAddresses", "string", title: "Added Devicse Segment Addresses (ex: 21, 22)", required: false, submitOnChange: true) input ("devPort", "string", title: "Device Port (default is 9999)", required: true, defaultValue: "9999", submitOnChange: true) if (devAddresses) { href "manAddStart", title: "Add Devices to the Device Array", description: "Press to continue" } href "startPage", title: "Exit without Updating", description: "Return to start page without attempting" } } } def manAddStart() { addDevicesPage("Manual") } def addDevicesPage(discType) { logDebug("addDevicesPage: [scan: ${scan}]") if (discType == "LAN") { def action = findDevices() } else if (discType == "Manual") { def action = manualGetDevices() } else if (discType == "CLOUD") { def action = cloudGetDevices() } def devices = state.devices def uninstalledDevices = [:] def requiredDrivers = [:] devices.each { def isChild = getChildDevice(it.value.dni) if (!isChild) { uninstalledDevices["${it.value.dni}"] = "${it.value.alias}, ${it.value.type}" requiredDrivers["${it.value.type}"] = "${it.value.type}" } } def reqDrivers = [] requiredDrivers.each { reqDrivers << it.key } def pageInstructions = "Before Installing New Devices " pageInstructions += "Assure the drivers listed below are installed." pageInstructions += "${reqDrivers}" return dynamicPage(name:"addDevicesPage", title: "Add Kasa Devices to Hubitat, Version ${appVersion()}", nextPage: addDevStatus, install: false) { section() { paragraph pageInstructions input "missingDevHelp", "bool", title: "Missing Device Help", submitOnChange: true, defaultalue: false if (missingDevHelp) { paragraph missingDeviceHelp() } input ("selectedAddDevices", "enum", required: false, multiple: true, title: "Devices to add (${uninstalledDevices.size() ?: 0} available).\n\t" + "Total Devices: ${devices.size()}", description: "Use the dropdown to select devices. Then select 'Done'.", options: uninstalledDevices) } } } def addDevStatus() { addDevices() logInfo("addDevStatus") def addMsg = "" if (state.addedDevices == null) { addMsg += "Added Devices: No devices added." } else { addMsg += "The following devices were installed:\n" state.addedDevices.each{ addMsg += "\t${it}\n" } } def failMsg = "" if (state.failedAdds == null) { failMsg += "Failed Adds: No devices failed to add." } else { failMsg += "The following devices were not installed:\n" state.failedAdds.each{ failMsg += "\t${it}\n" } failMsg += "\tMost common failure cause: Driver not installed." } return dynamicPage(name:"addDeviceStatus", title: "Installation Status, Version ${appVersion()}", nextPage: listDevices, install: false) { section() { paragraph addMsg paragraph failMsg } } app?.removeSetting("selectedAddDevices") } def addDevices() { logInfo("addDevices: [selectedDevices: ${selectedAddDevices}]") def hub = location.hubs[0] state.addedDevices = [] state.failedAdds = [] selectedAddDevices.each { dni -> def isChild = getChildDevice(dni) if (!isChild) { def device = state.devices.find { it.value.dni == dni } def deviceData = [:] deviceData["deviceIP"] = device.value.ip deviceData["devicePort"] = device.value.port deviceData["plugNo"] = device.value.plugNo deviceData["plugId"] = device.value.plugId deviceData["deviceId"] = device.value.deviceId deviceData["model"] = device.value.model deviceData["feature"] = device.value.feature try { addChildDevice( "davegut", device.value.type, device.value.dni, hub.id, [ "label": device.value.alias, "name" : device.value.type, "data" : deviceData ] ) state.addedDevices << [label: device.value.alias, ip: device.value.ip] logInfo("Installed ${device.value.alias}.") } catch (error) { state.failedAdds << [label: device.value.alias, driver: device.value.type, ip: device.value.ip] def msg = "addDevice: \nFailed to install device. Most likely " msg += "could not find driver ${device.value.type} in the " msg += "Hubitat Drivers Code page. Check that page." msg += "\nAdditional data: Device Data = ${device}.\n\r" logWarn(msg) } } pauseExecution(3000) } app?.removeSetting("selectedAddDevices") } def listDevices() { logInfo("listDevices") def theList = "" def theListTitle= "" def devices = state.devices if (devices == null) { theListTitle += "No devices in the device database." } else { theListTitle += "Total Kasa devices: ${devices.size() ?: 0}\n" theListTitle += "Alias: [Ip:Port, RSSI, Driver Version, Installed?]\n" def deviceList = [] devices.each{ def dni = it.key def driverVer = "n/a" def installed = "No" def isChild = getChildDevice(it.key) if (isChild) { driverVer = isChild.getDataValue("driverVersion") installed = "Yes" } deviceList << "${it.value.alias} - ${it.value.model}: [${it.value.ip}:${it.value.port}, ${it.value.rssi}, ${driverVer}, ${installed}]" } deviceList.sort() deviceList.each { theList += "${it}\n" } } return dynamicPage(name:"listDevices", title: "List Kasa Devices from Add Devices, Version ${appVersion()}", nextPage: startPage, install: false) { section() { paragraph theListTitle paragraph "
${theList}
" paragraph listNote() } } } // ===== Kasa Authentication def kasaAuthenticationPage() { logInfo("kasaAuthenticationPage") return dynamicPage (name: "kasaAuthenticationPage", title: "Initial Kasa Login Page, Version ${appVersion()}", nextPage: startPage, install: false) { def note = "You only need to enter your Kasa credentials and get a token " + "if you need to use the cloud integration. This is unusual. If you wish " + "to not get a token, simply press 'Exit without Credentials' below." + "\n\nIf you have already installed and find you need the cloud:" + "\na.\tEnter the credentials and get a token" + "\nb.\tRun Install Kasa Devices" section("Enter Kasa Account Credentials: ") { paragraph note input ("userName", "email", title: "TP-Link Kasa Email Address", required: false, submitOnChange: true) input ("userPassword", "password", title: "TP-Link Kasa Account Password", required: false, submitOnChange: true) if (userName && userPassword && userName != null && userPassword != null) { href "startGetToken", title: "Get or Update Kasa Token", description: "Tap to Get Kasa Token" href "startPage", title: "Exit without Updating", description: "Return to start page without getting token" } paragraph "Select '<' at upper left corner to exit." } } } def startGetToken() { logInfo("getTokenFromStart: Result = ${getToken()}") cloudAddDevicesPage() } def getToken() { logInfo("getToken ${userName}") app?.removeSetting("kasaToken") def message = "" def hub = location.hubs[0] def cmdBody = [ method: "login", params: [ appType: "Kasa_Android", cloudUserName: "${userName}", cloudPassword: "${userPassword}", terminalUUID: "${hub.id}"]] cmdData = [uri: "https://wap.tplinkcloud.com", cmdBody: cmdBody] def respData = sendKasaCmd(cmdData) if (respData.error_code == 0) { app?.updateSetting("kasaToken", respData.result.token) message = "Token updated to ${respData.result.token}" } else { message = "Token not updated. See WARN message in Log." logWarn("getToken: Token not updated. Return = ${respData}\n\r") } return message } def schedGetToken() { logInfo("schedGetToken: Result = ${getToken()}") } // ===== Generate the device database def findDevices() { def start = state.hostArray.min().toInteger() def finish = state.hostArray.max().toInteger() + 1 logDebug("findDevices: [hostArray: ${state.hostArray}, portArray: ${state.portArray}, pollSegment: ${state.segArray}]") state.portArray.each { def port = it.trim() List deviceIPs = [] state.segArray.each { def pollSegment = it.trim() logInfo("findDevices: Searching for LAN deivces on IP Segment = ${pollSegment}, port = ${port}") for(int i = start; i < finish; i++) { deviceIPs.add("${pollSegment}.${i.toString()}") } sendLanCmd(deviceIPs.join(','), port, """{"system":{"get_sysinfo":{}}}""", "getLanData") } } def delay = 50 * (finish - start) + 1000*commsTO() pauseExecution(delay) updateChildren() return } def manualGetDevices() { def addressArray = devAddresses.split('\\,') logInfo("manualGetDevices: [segment: ${lanSegment}, addresses: ${addressArray}, port: ${devPort}]") addressArray.each { def ip = "${lanSegment}.${it.trim()}" def ipInstalled = ipExists(ip) if (ipInstalled == false) { sendLanCmd(ip, devPort, """{"system":{"get_sysinfo":{}}}""", "getLanData", commsTO()) pauseExecution(5000) ipInstalled = ipExists(ip) if (ipInstalled == false) { sendLanCmd(ip, devPort, """{"system":{"get_sysinfo":{}}}""", "getLanData", 2*commsTO()) pauseExecution(5000) ipInstalled = ipExists(ip) if (ipInstalled == false) { sendLanCmd(ip, devPort, """{"system":{"get_sysinfo":{}}}""", "getLanData", 4*commsTO()) pauseExecution(5000) } } } else { logWarn("manualGetDevices: Kasa device already assigned to ${ip}") } ipInstalled = ipExists(ip) if (ipInstalled == false) { logWarn("manualGetDevices: A Kasa device was not detected at ${ip}") } } return } def ipExists(ip) { def exists = false state.devices.each{ if (it.value.ip == ip) { exists = true } } return exists } def getLanData(response) { if (response instanceof Map) { def lanData = parseLanData(response) if (lanData.error) { return } def cmdResp = lanData.cmdResp if (cmdResp.system) { cmdResp = cmdResp.system } parseDeviceData(cmdResp, lanData.ip, lanData.port) } else { response.each { def lanData = parseLanData(it) if (lanData.error) { return } def cmdResp = lanData.cmdResp if (cmdResp.system) { cmdResp = cmdResp.system } parseDeviceData(cmdResp, lanData.ip, lanData.port) if (lanData.cmdResp.children) { pauseExecution(120) } else { pauseExecution(40) } } } } def cloudGetDevices() { logInfo("cloudGetDevices ${kasaToken}") def message = "" def cmdData = [uri: "https://wap.tplinkcloud.com?token=${kasaToken}", cmdBody: [method: "getDeviceList"]] def respData = sendKasaCmd(cmdData) def cloudDevices def cloudUrl if (respData.error_code == 0) { cloudDevices = respData.result.deviceList cloudUrl = "" } else { message = "Devices not returned from Kasa Cloud." logWarn("cloudGetDevices: Devices not returned from Kasa Cloud. Return = ${respData}\n\r") return message } cloudDevices.each { if (it.deviceType != "IOT.SMARTPLUGSWITCH" && it.deviceType != "IOT.SMARTBULB" && it.deviceType != "IOT.IPCAMERA") { logInfo("cloudGetDevice: Ignore device type ${it.deviceType}.") } else if (it.status == 0) { logInfo("cloudGetDevice: Device name ${it.alias} is offline and not included.") cloudUrl = it.appServerUrl } else { cloudUrl = it.appServerUrl def cmdBody = [ method: "passthrough", params: [ deviceId: it.deviceId, requestData: """{"system":{"get_sysinfo":{}}}"""]] cmdData = [uri: "${cloudUrl}/?token=${kasaToken}", cmdBody: cmdBody] def cmdResp respData = sendKasaCmd(cmdData) if (respData.error_code == 0) { def jsonSlurper = new groovy.json.JsonSlurper() cmdResp = jsonSlurper.parseText(respData.result.responseData).system.get_sysinfo if (cmdResp.system) { cmdResp = cmdResp.system } parseDeviceData(cmdResp) } else { message = "Data for one or more devices not returned from Kasa Cloud.\n\r" logWarn("cloudGetDevices: Device datanot returned from Kasa Cloud. Return = ${respData}\n\r") return message } } } message += "Available device data sent to parse methods.\n\r" if (cloudUrl != "" && cloudUrl != kasaCloudUrl) { app?.updateSetting("kasaCloudUrl", cloudUrl) message += " kasaCloudUrl uptdated to ${cloudUrl}." } pauseExecution(5000) return message } def parseDeviceData(cmdResp, ip = "CLOUD", port = "CLOUD") { logDebug("parseDeviceData: ${cmdResp} // ${ip} // ${port}") def dni if (cmdResp.mic_mac) { dni = cmdResp.mic_mac } else { dni = cmdResp.mac.replace(/:/, "") } def devices = state.devices def kasaType if (cmdResp.mic_type) { kasaType = cmdResp.mic_type } else { kasaType = cmdResp.type } def type = "Kasa Plug Switch" def feature = cmdResp.feature if (kasaType == "IOT.SMARTPLUGSWITCH") { if (cmdResp.dev_name && cmdResp.dev_name.contains("Dimmer")) { feature = "dimmingSwitch" type = "Kasa Dimming Switch" } } else if (kasaType == "IOT.SMARTBULB") { if (cmdResp.lighting_effect_state) { feature = "lightStrip" type = "Kasa Light Strip" } else if (cmdResp.is_color == 1) { feature = "colorBulb" type = "Kasa Color Bulb" } else if (cmdResp.is_variable_color_temp == 1) { feature = "colorTempBulb" type = "Kasa CT Bulb" } else { feature = "monoBulb" type = "Kasa Mono Bulb" } } else if (kasaType == "IOT.IPCAMERA") { feature = cmdResp.f_list type = "CAM NOT SUPPORTED" } def model = cmdResp.model.substring(0,5) def alias = cmdResp.alias def rssi = cmdResp.rssi def deviceId = cmdResp.deviceId def plugNo def plugId if (cmdResp.children) { def childPlugs = cmdResp.children childPlugs.each { plugNo = it.id plugNo = it.id.substring(it.id.length() - 2) def childDni = "${dni}${plugNo}" plugId = "${deviceId}${plugNo}" alias = it.alias def existingDev = devices.find{ it.key == childDni} if ((existingDev && ip == "CLOUD") || (existingDev && ip == existingDev.value.ip)) { return } def device = createDevice(childDni, ip, port, rssi, type, feature, model, alias, deviceId, plugNo, plugId) devices["${childDni}"] = device logInfo("parseDeviceData: ${type} ${alias} (${ip}) added to devices array.") } } else { def existingDev = devices.find{ it.key == dni} if ((existingDev && ip == "CLOUD") || (existingDev && ip == existingDev.value.ip)) { return } def device = createDevice(dni, ip, port, rssi, type, feature, model, alias, deviceId, plugNo, plugId) devices["${dni}"] = device logInfo("parseDeviceData: ${type} ${alias} (${ip}) added to devices array.") } } def createDevice(dni, ip, port, rssi, type, feature, model, alias, deviceId, plugNo, plugId) { logDebug("createDevice: dni = ${dni}") def device = [:] device["dni"] = dni device["ip"] = ip device["port"] = port device["type"] = type device["rssi"] = rssi device["feature"] = feature device["model"] = model device["alias"] = alias device["deviceId"] = deviceId if (plugNo != null) { device["plugNo"] = plugNo device["plugId"] = plugId } return device } // ============================================ // ===== Remove Devices ======================= // ============================================ def removeDevicesPage() { logInfo("removeDevicesPage") def devices = state.devices def installedDevices = [:] devices.each { def installed = false def isChild = getChildDevice(it.value.dni) if (isChild) { installedDevices["${it.value.dni}"] = "${it.value.alias}, type = ${it.value.type}, dni = ${it.value.dni}" } } logDebug("removeDevicesPage: newDevices = ${newDevices}") return dynamicPage(name:"removedDevicesPage", title:"Remove Kasa Devices from Hubitat", nextPage: startPage, install: false) { section("Select Devices to Remove from Hubitat, Version ${appVersion()}") { input ("selectedRemoveDevices", "enum", required: false, multiple: true, title: "Devices to remove (${installedDevices.size() ?: 0} available)", description: "Use the dropdown to select devices. Then select 'Done'.", options: installedDevices) } } } def removeDevices() { logDebug("removeDevices: ${selectedRemoveDevices}") def devices = state.devices selectedRemoveDevices.each { dni -> def device = state.devices.find { it.value.dni == dni } def isChild = getChildDevice(dni) if (isChild) { try { deleteChildDevice(dni) logInfo("Deleted ${device.value.alias}") } catch (error) { logWarn("Failed to delet ${device.value.alias}.") } } } app?.removeSetting("selectedRemoveDevices") } // ============================================ // ===== Utility Methods ============= // ============================================ // ===== List Devices def listDevicesByIp() { logInfo("listDevicesByIp") def deviceList = getDeviceList("ip") deviceList.sort() def theListTitle = "Total Kasa devices: ${deviceList.size() ?: 0}\n" theListTitle += "[Ip:Port: [testResults, RSSI, Alias, Driver Version, Installed?]\n" def theList = "" deviceList.each { theList += "${it}\n" } return dynamicPage(name:"listDevicesByIp", title: "List Kasa Devices by IP with Lan Test Results, Version ${appVersion()}", nextPage: startPage, install: false) { section() { input "missingDevHelp", "bool", title: "Failed Device Help", submitOnChange: true, defaultalue: false if (missingDevHelp) { paragraph missingDeviceHelp() } paragraph theListTitle paragraph "${theList}
" paragraph listNote() } } } def listDevicesByName() { logInfo("listDevicesByName") def deviceList = getDeviceList("name") deviceList.sort() def theListTitle = "Total Kasa devices: ${deviceList.size() ?: 0}\n" theListTitle += "Alias: [testResults, RSSI, Ip:Port, Driver Version, Installed?]\n" def theList = "" deviceList.each { theList += "${it}\n" } return dynamicPage(name:"listDevicesByName", title: "List Kasa Devices by Name with Lan Test Results, Version ${appVersion()}", nextPage: startPage, install: false) { section() { input "missingDevHelp", "bool", title: "Failed Device Help", submitOnChange: true, defaultalue: false if (missingDevHelp) { paragraph missingDeviceHelp() } paragraph theListTitle paragraph "${theList}
" paragraph listNote() } } } def getDeviceList(sortType) { def test = runLanTest() def lanTest = state.lanTest def devices = state.devices def deviceList = [] if (devices == null) { deviceList << "No Devices in devices.]" } else { devices.each{ def dni = it.key def result = ["Failed", "n/a"] def testResult = lanTest.find { it.key == dni } if (testResult) { result = testResult.value } def driverVer = "ukn" def installed = "No" def isChild = getChildDevice(it.key) if (isChild) { driverVer = isChild.getDataValue("driverVersion") installed = "Yes" } if (sortType == "ip") { deviceList << "${it.value.ip}:${it.value.port}: [${result[0]}, ${result[1]}, ${it.value.alias}, ${driverVer}, ${installed}]" } else { deviceList << "${it.value.alias}: [${result[0]}, ${result[1]}, ${it.value.ip}:${it.value.port}, ${driverVer}, ${installed}]" } } } return deviceList } def runLanTest() { state.lanTest = [:] List deviceIPs = [] def devices = state.devices devices.each{ if (it.value.ip != "CLOUD") { if (it.value.plugNo == null || it.value.plugNo == "00") { deviceIPs.add(it.value.ip) } } else { state.lanTest << ["${it.key}": ["CLOUD", "n/a"]] } } if (deviceIPs.size() > 0) { sendLanCmd(deviceIPs.join(','), "9999", """{"system":{"get_sysinfo":{}}}""", "lanTestParse") def delay = 1000 + 1000*commsTO() + deviceIPs.size() * 50 pauseExecution(delay) } return } def lanTestParse(response) { if (response instanceof Map) { def lanData = parseLanData(response) if (lanData.error) { return } def cmdResp = lanData.cmdResp if (cmdResp.system) { cmdResp = cmdResp.system } lanTestResult(cmdResp, lanData.ip) } else { response.each { def lanData = parseLanData(it) if (lanData.error) { return } def cmdResp = lanData.cmdResp if (cmdResp.system) { cmdResp = cmdResp.system } lanTestResult(cmdResp, lanData.ip) if (lanData.cmdResp.children) { pauseExecution(120) } else { pauseExecution(40) } } } } def lanTestResult(cmdResp, ip) { def lanTest = state.lanTest def devices = state.devices def dni try { def thisDevice = devices.find { it.value.ip == ip } dni = thisDevice.key } catch (e) { logWarn("lanTestParse: LAN device with ip = ${ip} is not in devices database.") } if (cmdResp.children) { dni = dni.substring(0,12) def childPlugs = cmdResp.children childPlugs.each { def plugNo = it.id.substring(it.id.length() - 2) def childDni = "${dni}${plugNo}" lanTest << ["${childDni}": ["PASSED", "${cmdResp.rssi}"]] } } else { lanTest << ["${dni}": ["PASSED", "${cmdResp.rssi}"]] } state.lanTest = lanTest } // ===== Ping Testing def commsTest() { logInfo("commsTest") return dynamicPage(name:"commsTest", title: "IP Communications Test, Version ${appVersion()}", nextPage: startPage, install: false) { section() { def note = "This test measures ping from this Hub to any device on your " + "LAN (wifi and connected). You enter your Router's IP address, a " + "non-Kasa device (other hub if you have one), and select the Kasa " + "devices to ping. (Each ping will take about 3 seconds)." paragraph note input "routerIp", "string", title: "IP Address of your Router", required: false, submitOnChange: true input "nonKasaIp", "string", title: "IP Address of non-Kasa LAN device (other Hub?)", required: false, submitOnChange: true def devices = state.devices def kasaDevices = [:] devices.each { kasaDevices["${it.value.dni}"] = "${it.value.alias}, ${it.value.ip}" } input ("pingKasaDevices", "enum", required: false, multiple: true, title: "Kasa devices to ping (${kasaDevices.size() ?: 0} available).", description: "Use the dropdown to select devices.", options: kasaDevices) paragraph "Test will take approximately 5 seconds per device." href "commsTestDisplay", title: "Ping Selected Devices", description: "Click to Test IP Comms." href "startPage", title: "Exit without Testing", description: "Return to start page without attempting" } } } def commsTestDisplay() { logDebug("commsTestDisplay: [routerIp: ${routerIp}, nonKasaIp: ${nonKasaIp}, kasaDevices: ${pingKasaDevices}]") def pingResults = [] def pingResult if (routerIp != null) { pingResult = sendPing(routerIp, 5) pingResults << "Router: ${pingResult}" } if (nonKasaIp != null) { pingResult = sendPing(nonKasaIp, 5) pingResults << "nonKasaDevice: ${pingResult}" } def devices = state.devices if (pingKasaDevices != null) { pingKasaDevices.each {dni -> def device = devices.find { it.value.dni == dni } pingResult = sendPing(device.value.ip, 5) pingResults << "${device.value.alias}: ${pingResult}" } } def pingList = "" pingResults.each { pingList += "${it}\n" } return dynamicPage(name:"commsTestDisplay", title: "Ping Testing Result, Version ${appVersion()}", nextPage: commsTest, install: false) { section() { def note = "Expectations:\na.\tAll devices have similar ping results." + "\nb.\tAll pings are less than 1000 ms.\nc.\tSuccess is 100." + "\nIf not, test again to verify bad results." + "\nAll times are in ms. Success is percent of 5 total tests." paragraph note paragraph "${pingList}
" } } } def sendPing(ip, count = 3) { hubitat.helper.NetworkUtils.PingData pingData = hubitat.helper.NetworkUtils.ping(ip, count) def success = "nullResults" def minTime = "n/a" def maxTime = "n/a" if (pingData) { success = (100 * pingData.packetsReceived.toInteger() / count).toInteger() minTime = pingData.rttMin maxTime = pingData.rttMax } def pingResult = [ip: ip, min: minTime, max: maxTime, success: success] return pingResult } // ===== Zero devices database and refind devices def dbReset() { logInfo("dbReset") state.devices = [:] def action = findDevices() return dynamicPage(name:"dbReset", title: "Reset the Kasa Device Database, Version ${appVersion()}", nextPage: listDevices, install: false) { def notice = "The device database has been reset and devices rediscovered.\n\r" + " Total devices = ${state.devices.size()}" section() { paragraph notice } } } // ============================================ // ===== App and Device configuration ========= // ============================================ def updateConfigurations(force = false) { def msg = "" if (configureEnabled == true || configureEnabled == null || force) { app?.updateSetting("configureEnabled", [type:"bool", value: false]) runIn(900, configureEnable) runIn(3, configureChildren) msg += "Updating App and all device configurations" } else { msg += "Not executed. Ran method witin last 15 minutes." } logInfo("updateConfigurations: ${msg}") return msg } def configureEnable() { logDebug("configureEnable: Enabling configureDevices") app?.updateSetting("configureEnabled", [type:"bool", value: true]) } def configureChildren() { schedule("15 05 1 * * ?", updateConfigurations) app?.updateSetting("pollEnabled", [type:"bool", value: true]) def fixConnect = fixConnection() def manifestData = getManifestData() def children = getChildDevices() children.each { it.childConfigure(manifestData) pauseExecution(1000) } } def getManifestData() { logDebug("getManifestData: updateManifest = ${state.updateManifest}") def msg = "getManifestData: " def manifestData = [currVersion: appVersion().trim(), releaseNotes: "Default", updateAvailable: false, appVersion: appVersion().trim()] def params = [uri: "https://raw.githubusercontent.com/DaveGut/HubitatActive/master/KasaDevices/packageManifest.json", contentType: "text/plain; charset=UTF-8"] def manifest try { httpGet(params) { resp -> manifest = new JsonSlurper().parseText(resp.data.text) } } catch (e) { logWarn("getManifestData: ${e}") } def updateAvailable = false if (manifest.version.trim() != appVersion().trim()) { updateAvailable = true app.updateLabel("Kasa Integration Update Available") msg += "\n\t\t\t* App update available." } else if (app.getLabel() != "Kasa Integration"){ app.updateLabel("Kasa Integration") msg += "\n\t\t\t* App is up to date and name set to default." } manifestData = [currVersion: manifest.version.trim(), releaseNotes: manifest.releaseNotes, updateAvailable: updateAvailable, appVersion: appVersion().trim()] state.manifestData = manifestData state.updateAvailable = updateAvailable msg += "\n\t\t\t* ${manifestData}" logInfo(msg) return manifestData } // ============================================ // ===== Update Child Connections ============= // ============================================ def fixConnection() { def msg = "fixConnection: " if (pollEnabled == true || pollEnabled == null) { msg += execFixConnection() msg += "Checking and updating all device IPs." } else { msg += "[pollEnabled: false]" } logInfo(msg) return msg } def pollEnable() { logDebug("pollEnable: Enabling IP check from device error.") app?.updateSetting("pollEnabled", [type:"bool", value: true]) } def execFixConnection() { def message = [:] app?.updateSetting("pollEnabled", [type:"bool", value: false]) runIn(900, pollEnable) def pollDevs = findDevices() message << [segmentArray: state.segArray, hostArray: state.hostArray, portArray: state.portArray] def tokenUpd = false if (kasaToken && userName != "") { def token = getToken() tokenUpd = true } message << [tokenUpdated: tokenUpd] // updateChildren() return message } def updateChildren() { def devices = state.devices devices.each { def child = getChildDevice(it.key) if (child) { if (it.value.ip != null || it.value.ip != "" || it.value.ip != "CLOUD") { child.updateDataValue("deviceIP", it.value.ip) child.updateDataValue("devicePort", it.value.port.toString()) def logData = [deviceIP: it.value.ip,port: it.value.port] logDebug("updateChildDeviceData: [${it.value.alias}: ${logData}]") } } } } // ============================================ // ===== Synchronize data between devices ===== // ============================================ // ===== Color Bulbs and Light Strips Presets def syncBulbPresets(bulbPresets) { logDebug("syncBulbPresets") def devices = state.devices devices.each { def type = it.value.type if (type == "Kasa Color Bulb" || type == "Kasa Light Strip") { def child = getChildDevice(it.value.dni) if (child) { child.updatePresets(bulbPresets) } } } } def resetStates(deviceNetworkId) { logDebug("resetStates: ${deviceNetworkId}") def devices = state.devices devices.each { def type = it.value.type def dni = it.value.dni if (type == "Kasa Light Strip") { def child = getChildDevice(dni) if (child && dni != deviceNetworkId) { child.resetStates() } } } } def syncEffectPreset(effData, deviceNetworkId) { logDebug("syncEffectPreset: ${effData.name} || ${deviceNetworkId}") def devices = state.devices devices.each { def type = it.value.type def dni = it.value.dni if (type == "Kasa Light Strip") { def child = getChildDevice(dni) if (child && dni != deviceNetworkId) { child.updateEffectPreset(effData) } } } } // ===== Multiplug data coordination def coordinate(cType, coordData, deviceId, plugNo) { logDebug("coordinate: ${cType}, ${coordData}, ${deviceId}, ${plugNo}") def plugs = state.devices.findAll{ it.value.deviceId == deviceId } plugs.each { if (it.value.plugNo != plugNo) { def child = getChildDevice(it.value.dni) if (child) { child.coordUpdate(cType, coordData) pauseExecution(700) } } } } // ============================================ // ===== Communications methods =============== // ============================================ // ===== LAN Comms private sendLanCmd(ip, port, command, action, commsTo = commsTO()) { def myHubAction = new hubitat.device.HubAction( outputXOR(command), hubitat.device.Protocol.LAN, [type: hubitat.device.HubAction.Type.LAN_TYPE_UDPCLIENT, destinationAddress: "${ip}:${port}", encoding: hubitat.device.HubAction.Encoding.HEX_STRING, parseWarning: true, timeout: commsTo, callback: action]) try { sendHubCommand(myHubAction) } catch (error) { logWarn("sendLanCmd: command to ${ip}:${port} failed. Error = ${error}") } } def parseLanData(response) { def lanData def resp = parseLanMessage(response.description) if (resp.type == "LAN_TYPE_UDPCLIENT") { def ip = convertHexToIP(resp.ip) def port = convertHexToInt(resp.port) def clearResp = inputXOR(resp.payload) if (clearResp.length() > 1022) { clearResp = clearResp.substring(0,clearResp.indexOf("preferred")-2) + "}}}" } def cmdResp = new JsonSlurper().parseText(clearResp).system.get_sysinfo lanData = [cmdResp: cmdResp, ip: ip, port: port] } else { lanData = [error: "error"] } return lanData } private outputXOR(command) { def str = "" def encrCmd = "" def key = 0xAB for (int i = 0; i < command.length(); i++) { str = (command.charAt(i) as byte) ^ key key = str encrCmd += Integer.toHexString(str) } return encrCmd } private inputXOR(encrResponse) { String[] strBytes = encrResponse.split("(?<=\\G.{2})") def cmdResponse = "" def key = 0xAB def nextKey byte[] XORtemp for(int i = 0; i < strBytes.length-1; i++) { nextKey = (byte)Integer.parseInt(strBytes[i], 16) // could be negative XORtemp = nextKey ^ key key = nextKey cmdResponse += new String(XORtemp) } return cmdResponse } // ===== Cloud Comms def sendKasaCmd(cmdData) { def commandParams = [ uri: cmdData.uri, requestContentType: 'application/json', contentType: 'application/json', headers: ['Accept':'application/json; version=1, */*; q=0.01'], body : new groovy.json.JsonBuilder(cmdData.cmdBody).toString() ] def respData try { httpPostJson(commandParams) {resp -> if (resp.status == 200) { respData = resp.data } else { def msg = "sendKasaCmd: HTTP Status not equal to 200. Protocol error. " msg += "HTTP Protocol Status = ${resp.status}" logWarn(msg) respData = [error_code: resp.status, msg: "HTTP Protocol Error"] } } } catch (e) { def msg = "sendKasaCmd: Error in Cloud Communications. The Kasa Cloud is unreachable." msg += "\nAdditional Data: Error = ${e}\n\n" logWarn(msg) respData = [error_code: 9999, msg: e] } return respData } // ============================================ // ===== Utility methods ====================== // ============================================ def installNote() { def installNotes = "There are three methods to install the devices. The methods " installNotes += "first search and validate devices and then offer the devices " installNotes += "for installation into Hubitat." installNotes += "\nPrior to using this install:" installNotes += "\n1. Install the device using the Kasa Phone App." installNotes += "\n2. Assign the device a static IP in your router/switch." installNotes += "\n3. Open the Kasa app and verify devices are controllable." installNotes += "\n4. Turn on any lights/bulbs." installNotes += "\n5. DO NOT close the Kasa App." installNotes += "\n\nLAN Scan (preferred method): Poll each " installNotes += "device in the ranges once. Can miss devices in some cases. " installNotes += "if so, try Manual to validate/add the missing devices." installNotes += "\na. Select 'Modify Configuration' to configure ranges (if required)." installNotes += "\nb. Select 'Scan LAN for Kasa devices and add'." installNotes += "\n\nManual: Use when the 'Scan LAN' method fails to discover " installNotes += "devices after several tries. Requires IP addresses " installNotes += "for each device to add, the App polls multiple times if necessary to " installNotes += "validate the devices." installNotes += "\na. Select 'Manually enter data then add Kasa devices'." installNotes += "\nb. Enter the LAN Segment and Port (if not the defaults)." installNotes += "\nc. Enter the host addresses for devices (i.e., '21,44,56')." installNotes += "\nd. Select 'Add Devices to Device Array'." installNotes += "\n\nKasa Cloud Installation. For use for some Kasa devices that " installNotes += "can not be controlled via the LAN. Queries the Kasa cloud. Thus " installNotes += "requires entry of your Kasa Cloud credentials. Use to augment the " installNotes += "other discovery methods or when a device does not support LAN control." installNotes += "\na. Select 'Get Kasa devices from the Kasa Cloud and add'." installNotes += "\nb. Select 'Kasa Login and Token Update' if not previously done." installNotes += "\nc. Enter your credentials and select 'Next'." installNotes += "\nd. Select 'Add Devices to Device Array'." return installNotes } def listNote() { def note = "RSSI: Device's received wifi signal strength. Values " + "less than -65 should be considered marginal." + "\nDriverVer: Current installed device driver version. For " + "uninstalled devices, the value is 'n/a'." return note } def missingDeviceHelp() { def text = "Try these steps. Stop the steps when all the devices have 'PASSED'." text += "\n(Note: If running a Test Device command, check the list again. Just " text += "selecting this help has run the test again and may clear error.)" text += "\na.\tRerun the Add command twice in succession." text += "\nb.\tOpen the Kasa phone app and exercise the 'failed' device(s)." text += "\n\tThen leave the device in an ON state and rerun the test." text += "\nc.\tCheck your LAN configuration for anything you have changed." text += "\n\t1)\tHave you added any wifi 6 device on the 2.4 GHz segment?" text += "\n\t\tActive wifi 6 devices can interfere with other devices." text += "\n\t\tAssure these devices are OFF and retest. (Look on web for" text += "\n\t\tWifi 6 issues with legacy IOT devices with your router." text += "\n\t2)\tOther changes? Back these out and try again." text += "\nd.\tTry controlling the device via the Device's edit page. If" text += "\n\t\tit works, then OK (The driver includes error handling.)" text += "\ne.\tSend a PM to the developer." } 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 debugOff() { app.updateSetting("debugLog", false) } def logTrace(msg) { log.trace "[KasaInt: ${appVersion()}]: ${msg}" } def logDebug(msg){ if(debugLog == true) { log.debug "[KasaInt: ${appVersion()}]: ${msg}" } } def logInfo(msg) { log.info "[KasaInt: ${appVersion()}]: ${msg}" } def logWarn(msg) { log.warn "[KasaInt: ${appVersion()}]: ${msg}" }