/* * Import URL: https://raw.githubusercontent.com/jakelehner/Hubitat/master/WyzeHub/apps/wyzehub-app.groovy * * DON'T BE A DICK PUBLIC LICENSE * * Version 1.1, December 2016 * * Copyright (C) 2021 Jake Lehner * * Everyone is permitted to copy and distribute verbatim or modified * copies of this license document. * * DON'T BE A DICK PUBLIC LICENSE * TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION * * 1. Do whatever you like with the original work, just don't be a dick. * * Being a dick includes - but is not limited to - the following instances: * * 1a. Outright copyright infringement - Don't just copy this and change the name. * 1b. Selling the unmodified original with no work done what-so-ever, that's REALLY being a dick. * 1c. Modifying the original work to contain hidden harmful content. That would make you a PROPER dick. * * 2. If you become rich through modifications, related works/services, or supporting the original work, * share the love. Only a dick would make loads off this work and not buy the original work's * creator(s) a pint. * * 3. Code is provided with no warranty. Using somebody else's code and bitching when it goes wrong makes * you a DONKEY dick. Fix the problem yourself. A non-dick would submit the fix back. * * =================================================================================== * * Release Notes: * v1.3 - Address issue with refresh token logic. * - Add Light Strip Support (non-pro) * v1.2 - Bugfix allowing Meshlight to be used with rules engine (thanks @bruderbell!) * - Add Camera Event Recording Enable/Disable (thanks @fieldsjm!) * v1.1 - Add Camera Driver (thanks @fieldsjm!) * - Add Outdoor Plug * - Hubitat Package Manager * v1.0 - Initial Release. * - Support for Color Bulbs, Plugs, and associated groups. * */ import groovy.json.JsonBuilder import groovy.transform.Field import java.security.MessageDigest import static java.util.UUID.randomUUID public static final String version() { return "v1.4" } public static final String apiAppName() { return "com.hualai" } public static final String apiAppVersion() { return "2.19.14" } @Field static final String childNamespace = "jakelehner" @Field static final Map groupDriverMap = [ 1: [label: 'Camera Group', driver: 'WyzeHub Camera Group'], 2: [label: 'Bulb Group', driver: 'WyzeHub Bulb Group'], 5: [label: 'Plug Group', driver: 'WyzeHub Plug Group'], 8: [label: 'Color Bulb Group', driver: 'WyzeHub Color Bulb Group'], ] @Field static final List ignoreDeviceModels = [ 'WLPPO' ] @Field static final Map driverMap = [ 'Light': [label: 'Bulb', driver: 'WyzeHub Bulb'], 'MeshLight': [label: 'Color Bulb', driver: 'WyzeHub Color Bulb'], 'LightStrip' : [label: 'Light Strip', driver: 'WyzeHub Color Bulb'], 'Plug': [label: 'Plug', driver: 'WyzeHub Plug'], 'OutdoorPlug': [label: 'Outdoor Plug', driver: 'WyzeHub Plug'], 'Camera': [label: 'Camera', driver: 'WyzeHub Camera'], 'default': [label: 'Unsupported Type', driver: null] ] @Field static final String log_level_debug = '5' @Field static final String log_level_info = '4' @Field static final String log_level_notice = '3' @Field static final String log_level_warn = '2' @Field static final String log_level_error = '1' @Field static final String log_level_off = '0' @Field static final String log_level_default = '4' String wyzeAuthBaseUrl() { return "https://auth-prod.api.wyze.com" } String wyzeApiBaseUrl() { return "https://api.wyzecam.com" } Map wyzeRequestHeaders() { return [ "x-api-key": "WMXHYf79Nr5gIlt3r0r7p9Tcw5bvs6BB4U8O8nGJ", "Content-Type": "application/json", "Accept": "application/json", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1 Safari/605.1.15" ] } Map wyzeRequestBody() { return [ 'access_token': state.access_token, 'app_name': apiAppName(), 'app_ver': apiAppName() + "_v" + apiAppVersion(), 'app_version': apiAppVersion(), 'phone_id': getPhoneId(), 'phone_system_type': 2, 'sc': '9f275790cab94a72bd206c8876429f3c', 'ts': (new Date()).getTime() ] } definition( name: 'WyzeHub', namespace: 'jakelehner', author: 'Jake Lehner', description: 'Hubitat Integration for Wyze WiFi devices.', iconUrl: '', iconX2Url: '', iconX3Url: '', installOnOpen: true, singleInstance: true, oauth: false, importUrl: 'https://raw.githubusercontent.com/jakelehner/Hubitat/master/WyzeHub/apps/wyzehub-app.groovy' ) preferences { page(name: 'pageMenu') page(name: 'pageAuthSettings') page(name: 'pageDoAuth') page(name: 'pageDoMfaAuth') page(name: 'pageSelectDevices') page(name: 'pageSelectDeviceGroups') } // --------------------- // | App Control Methods | // --------------------- def installed() { logDebug('installed()') clearState() initialize() state.serverInstalled = true logInfo('App Installed') } def updated() { logDebug('updated()') if (debugOutput) runIn(300,debugOff) //disable debug logs after 5 min initialize() } def initialize() { logDebug('initialize()') if (!state.deviceCache) { state.deviceCache = [ 'groups': [:], 'devices': [:], 'groupDeviceMacs': [] ] } if (!state.deviceParentMap) { state.deviceParentMap = [:] } if(settings['addDevices']) { logDebug('clearing addDevices cache') app.removeSetting('addDevices') } if (logEnable) { logInfo('Logging is Enabled') if (debugEnabled) { logDebug('Debug Logging is Enabled') } } } def uninstalled() { logDebug('uninstalled()') logInfo("Uninstalling...") logInfo('Clearing State...') clearState() logInfo("Deleting child devices...") getChildDevices().each { device-> logDebug("Deleting device: " + device.deviceNetworkId) deleteChildDevice(device.deviceNetworkId) } logInfo("Uninstalled") } // ------- // | Pages | // ------- def getCopyright() {'© 2021 Your Mom'} def pageMenu() { logDebug('pageMenu()') if (settings["devicesToAdd"]) { logDebug("New devices selected. Creating...") addDevices(settings["devicesToAdd"]) app.removeSetting('devicesToAdd') } if (settings["deviceGroupsToAdd"]) { logDebug("New groups selected. Creating...") addDeviceGroups(settings["deviceGroupsToAdd"]) app.removeSetting('deviceGroupsToAdd') } if (!(settings.username && settings.password)) { logDebug('Auth Credentials not set. Forwarding to pageAuthSettings...') return pageAuthSettings() } initialize() return dynamicPage( name: 'pageMenu', title: "${app.label}", install: true, uninstall: true, refreshInterval: 0 ) { section() { href(name: 'hrefSelectDeviceGroups', title: 'Select Device Groups', description: '', page: 'pageSelectDeviceGroups', image: '') href(name: 'hrefSelectDevices', title: 'Select Devices', description: '', page: 'pageSelectDevices', image: '') href(name: 'hrefAuthSettings', title: 'Configure Login Info', description: '', page: 'pageAuthSettings') } section('App Options') { logLevelOptions = [ '5': 'Debug', '4': 'Info', '2': 'Warn', '1': 'Error', '0': 'Off', ] input name: "logLevel", type: "enum", title: "Log Level", required: true, defaultValue: '4', submitOnChange: true, options: logLevelOptions } displayFooter() } } def pageAuthSettings() { logDebug('pageAuthSettings()') return dynamicPage( name: 'pageAuthSettings', title: "${app.label} Account Info", install: false, uninstall: false, refreshInterval: 0, nextPage: 'pageDoAuth' ) { section('User Authentication') { input name: 'username', type: 'text', title: 'Username', required: true, submitOnChange: true input name: 'password', type: 'password', title: 'Password', required: true, submitOnChange: true } section('Manually Enter Tokens (Troubleshooting)') { input name: 'access_token', type: 'text', title: 'Access Token', required: false, submitOnChange: true input name: 'refresh_token', type: 'text', title: 'Refresh Token', required: false, submitOnChange: true } displayFooter() } } def pageDoAuth() { logDebug('pageDoAuth()') if (!(settings.username && settings.password)) { logError('Made it to "Do Auth" but credentials not set? Forwarding to pageAuthSettings...') return pageAuthSettings() } nextPage = 'pageMenu' loggedIn = false mfaEnabled = false clearState() // Token Override? if (settings.access_token) { logDebug('Token Override') state.access_token = settings.access_token state.refresh_token = settings.refresh_token } else { authenticateWyzeAccount(settings.username, settings.password) if (state.access_token) { logDebug('access token found') logDebug(state.access_token) loggedIn = true } else if (state.mfa_options) { mfaEnabled = true nextPage = 'pageDoMfaAuth' if (state.mfa_options.contains('TotpVerificationCode')) { // TOTP logInfo('TOTP MFA Enabled') mfaTitle = 'TOTP MFA Enabled' mfaText = 'Enter TOTP Code' state.mfa_type = 'TotpVerificationCode' } else if(state.mfa_options.contains('PrimaryPhone')) { // SMS logInfo('SMS MFA Enabled') mfaTitle = 'SMS MFA Enabled' mfaText = 'Enter SMS Code' state.mfa_type = 'PrimaryPhone' sendSmsCode('Primary', state.sms_session_id, state.user_id) } else { logError('No supported MFA Types Found') logDebug(state.mfa_options) } } } return dynamicPage( name: 'pageAuthSettings', title: "${app.label} Authentication", install: false, uninstall: false, refreshInterval: 0, nextPage: nextPage ) { if (loggedIn) { section() { paragraph("Logged In!") } } else if (mfaEnabled) { mfaCode = null settings.mfaCode = null section('MFA Enabled') { input name: 'mfaCode', type: 'password', title: mfaText, defaultValue: '', required: true, submitOnChange: false } } displayFooter() } } def pageDoMfaAuth() { logDebug('pageDoMfaAuth()') if (state.mfa_type == 'PrimaryPhone') { verificationId = state.sms_session_id verificationCode = settings.mfaCode } else if (state.mfa_type == 'TotpVerificationCode') { verificationId = state.mfa_details.totp_apps[0]['app_id'] verificationCode = settings.mfaCode } else { return 'pageAuthSettings' } loggedIn = false authenticateWyzeAccount( settings.username, settings.password, state.mfa_type, verificationId, verificationCode ) if (state.access_token) { logDebug('access token found') loggedIn = true } else { logError('MFA Login Error') } return dynamicPage( name: 'pageAuthSettings', title: "${app.label} Authentication", install: false, uninstall: false, refreshInterval: 0, nextPage: 'pageMenu' ) { if (loggedIn) { section() { paragraph("Logged In!") } } else { section() { "Failed logging in with MFA." } } displayFooter() } } def pageSelectDeviceGroups() { logDebug('pageSelectDeviceGroups()') updateDeviceCache() { response -> deviceCache = response } List newGroups = [] List unsupportedGroups = [] if (deviceCache.groups) { deviceCache.groups.each { mac, group -> groupType = groupDriverMap[group.group_type_id] networkId = generateGroupNetworkId(group) if (getChildDevice(networkId)) { logDebug("${group.group_name} (${networkId}) already exists. Skipping...") return } if(!groupType) { logDebug("${group.group_name} (${networkId}) unsupported. Skipping...") unsupportedGroups << group return } logDebug("${group.group_name} (${networkId}) found. Adding to selection list...") newGroups << [(group.group_id): "[${groupType.label}] ${group.group_name}"] } // Sort newGroups = newGroups.sort { a, b -> a.entrySet().iterator().next()?.value <=> b.entrySet().iterator().next()?.value } unsupportedGroups = unsupportedGroups.sort { it.value } } return dynamicPage( name: 'pageSelectDeviceGroups', title: "${app.label} Device Group Selection", install: false, uninstall: false, refreshInterval: 0, nextPage: 'pageMenu' ) { section() { paragraph("This page will list any new supported device groups.") } if (!newGroups) { section("No New Device Groups Found...") { input(name: "btnDeviceRefresh", type: "button", title: "Refresh", submitOnChange: true) } } else { section('Add Device Groups') { input(name: "deviceGroupsToAdd", type: "enum", title: "Select Device Groups to add:", submitOnChange: false, multiple: true, options: newGroups) } } if(unsupportedGroups) { section('Unsupported Device Groups') { unsupportedGroups.each{ group -> paragraph " - [${group.group_type_id}] ${group.group_name}" } } } displayFooter() } } def pageSelectDevices() { logDebug('pageSelectDevices()') updateDeviceCache() { response -> deviceCache = response } List newDevices = [] List unsupportedDevices = [] if (deviceCache.devices) { deviceCache.devices.each { mac, device -> if (ignoreDeviceModels.contains(device.product_model)) { logDebug("${device.nickname} (${device.mac}) is ignored device model. Skipping...") return } productType = driverMap[device.product_type] if (getChildDevice(device.mac)) { logDebug("${device.nickname} (${device.mac}) already exists. Skipping...") return } if (deviceCache.groupDeviceMacs.contains(device.mac)) { logDebug("${device.nickname} (${device.mac}) belongs to a group. Skipping...") return } if(!productType) { logDebug("${device.nickname} (${device.mac}) unsupported. Model: ${device.product_model}. Type: ${device.product_type}. Skipping...") unsupportedDevices << device return } logDebug("${device.nickname} (${device.mac}) found. Adding to selection list...") newDevices << [(device.mac): "[${productType.label}] ${device.nickname}"] } // Sort newDevices = newDevices.sort { a, b -> a.entrySet().iterator().next()?.value <=> b.entrySet().iterator().next()?.value } unsupportedDevices = unsupportedDevices.sort { it.value } } return dynamicPage( name: 'pageSelectDevices', title: "${app.label} Device Selection", install: false, uninstall: false, refreshInterval: 0, nextPage: 'pageMenu' ) { section() { paragraph('This page will list any new devices that do not belong to a device group.') } if (!newDevices) { section("No New Devices Found...") { input(name: "btnDeviceRefresh", type: "button", title: "Refresh", submitOnChange: true) } } else { section('Add Devices') { input(name: "devicesToAdd", type: "enum", title: "Select Devices to add:", submitOnChange: false, multiple: true, options: newDevices) } } if(unsupportedDevices) { section('Unsupported Devices') { unsupportedDevices.each{ device -> paragraph " - [${device.product_type}] ${device.nickname}" } } } displayFooter() } } def displayFooter() { section{ paragraph getFormat("line") paragraph "