diff --git a/switchbotContactSensor b/switchbotContactSensor index 65749e4..36df0f9 100644 --- a/switchbotContactSensor +++ b/switchbotContactSensor @@ -70,4 +70,3 @@ def parse(body) sendEvent(name: "motion", value: ([true, "DETECTED"].contains(body.moveDetected)) ? "active" : "inactive") } } - diff --git a/switchbotEventsApp b/switchbotEventsApp index 0c99e28..55a8d85 100644 --- a/switchbotEventsApp +++ b/switchbotEventsApp @@ -157,15 +157,22 @@ def observeEvent() def processEvent(eventMap) { - if(!eventMap) { return } - logDebug("incoming event: ${eventMap}") + if(!eventMap) { return } + if("changeReport" != eventMap?.eventType) { return } // check whether we know about this type of device def devTypeInfo = deviceTypesMap.getAt(eventMap.context.deviceType) - if(!devTypeInfo) { return } + if(!devTypeInfo) { + if (eventMap.context.lockState) { + // This is a Smart Lock Pro which doesn't include device type in the message + devTypeInfo = deviceTypesMap.getAt("WoLock") + logDebug("Received event with no device type, mapping to Smart Lock") + } + else return + } // build the event that we'll pass to the driver def newEvent = [:] @@ -196,6 +203,7 @@ import groovy.transform.Field [ WoContact: [name: "Contact Sensor", attrs: contactSensorAttrsMap], WoLock: [name: "Smart Lock"], + WoLockPro: [name: "Smart Lock"] WoMeter: [name: "Meter"], WoMeterPlus: [name: "Meter"], WoHub2: [name: "Meter"], @@ -203,7 +211,8 @@ import groovy.transform.Field WoPlugJP: [name: "Plug Mini", attrs: commonSwitchAttrsMap], WoPlugUS: [name: "Plug Mini", attrs: commonSwitchAttrsMap], WoPresence: [name: "Motion Sensor", attrs: motionSensorAttrsMap], - WoStrip: [name: "Strip Light", attrs: commonSwitchAttrsMap], + WoStrip: [name: "Strip Light", attrs: commonSwitchAttrsMap], + WoKeypadTouch: [name: "Keypad Touch"], ] static @Field allDevsAttrs = diff --git a/switchbotKeypadTouch b/switchbotKeypadTouch new file mode 100644 index 0000000..6c6b969 --- /dev/null +++ b/switchbotKeypadTouch @@ -0,0 +1,310 @@ +/* + +Copyright 2024 - dsegall + +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. + +------------------------------------------- + +Change history: + +0.9.22 dsegall Added Keypad Touch + +*/ + +import groovy.json.JsonBuilder +import groovy.transform.Field + +import java.util.Calendar +import javax.crypto.* +import javax.crypto.spec.* + +import hubitat.helper.HexUtils + +@Field static final String PERMANENT = "permanent" +@Field static final String TIMED = "timeLimit" +@Field static final String DISPOSABLE = "disposable" +@Field static final String EMERGENCY = "urgent" + +@Field static final String SUCCESS = "success" +@Field static final String FAILED = "failed" +@Field static final String TIMEOUT = "timeout" + +@Field static final String PASSCODE_NORMAL = "normal" +@Field static final String PASSCODE_EXPIRED = "expired" + +@Field static final MILLISECONDS_IN_DAY = 86400000 +@Field static final MILLISECONDS_IN_HOUR = 3600000 + +metadata +{ + definition(name: "SwitchBot Keypad Touch", namespace: "tomw", author: "dsegall", importUrl: "") + { + capability "Refresh" + capability "LockCodes" + capability "Initialize" + + command "createKey", [[name: "Name*", type: "STRING"], [name: "Password*", type: "STRING"], [name: "Type", type: "ENUM", constraints: [PERMANENT, TIMED, DISPOSABLE, EMERGENCY]], [name: "Start Time", type: "STRING"], [name: "End Time", type: "STRING"]] + command "deleteKeyByName", [[name: "Name*", type: "STRING"]] + } + + preferences + { + section + { + input name: "logEnable", type: "bool", title: "Enable debug logging", defaultValue: false + input name: "lockCodeEncryption", type: "bool", title: "Enable lock code encryption", defaultValue: false + input name: "deleteExpiredCodes", type: "bool", title: "Automatically delete expired codes", defaultValue: true + } + } +} + +def logDebug(msg) +{ + if (logEnable) + { + log.debug(msg) + } +} + +def initialize() +{ + sendEvent(name: "codeLength", value: 12) + sendEvent(name: "maxCodes", value: 100) + refresh() +} + +def updated() { + updateCodesAttr() +} + +def refresh() +{ + readDeviceDetails() +} + +def parse(body) +{ + if (logEnable) + logDebug("parse: ${body}") + + if (body.result == "success") { + if (body.eventName == "createKey") + sendEvent(name: "codeChanged", value: "added") + else if (body.eventName == "deleteKey") + sendEvent(name: "codeChanged", value: "deleted") + + atomicState.lastSuccessfulModificationTime = new Date() + logDebug("Scheduling refresh task...") + runIn(30000, "refresh") + } + else sendEvent(name: "codeChanged", value: "failed") +} + +def writeDeviceCommand(command, parameter = "default", commandType = "command") +{ + def id = getParent()?.getId(device.getDeviceNetworkId()) + if(id) + { + getParent()?.writeDeviceCommand(id, command, parameter, commandType) + } +} + +def createKey(String name, String code, String type=PERMANENT, String startTimeS=null, String endTimeS=null) { + if (!atomicState.lockCodesByName.containsKey(name)) { + logDebug("createKey ${name}") + long startTime = 0 + long endTime = 0 + Date now = new Date() + + if (type == TIMED && !startTimeS) { + startTime = now.getTime() + 5000 + } + + if (startTimeS) { + Calendar cal = timeToday(startTimeS) + Date date = cal.getTime() + startTime = date.getTime() + if (startTime < now.getTime()) + startTime = now.getTime() + 5000 + } + + if (type == TIMED && !endTimeS) { + endTimeS = "23:59" + } + + if (endTimeS) { + Calendar cal = timeToday(endTimeS) + Date date = cal.getTime() + endTime = date.getTime() + if (now.after(date)) { + if (startTimeS) startTime += MILLISECONDS_IN_DAY + endTime += MILLISECONDS_IN_DAY + } + + if (startTimeS && endTime - startTime < MILLISECONDS_IN_HOUR) + endTime = startTime + MILLISECONDS_IN_HOUR + 60000 + } + + Map params = ["name": name, "type": type, "password": code, "startTime": startTime, "endTime": endTime] + try { + writeDeviceCommand("createKey", params) + } + catch (Exception e) { + logDebug("createKey failed: ${e.getMessage()}") + } + } +} + +Calendar timeToday(String time) { + Calendar today = Calendar.getInstance() + today.set(Calendar.SECOND, 0) + today.set(Calendar.MILLISECOND, 0) + String[] parts = time.split(":") + today.set(Calendar.HOUR_OF_DAY, parts[0] as int) + today.set(Calendar.MINUTE, parts[1] as int) + return today +} + +def deleteKey(String id) { + logDebug("deleteKey id=${id}") + try { + writeDeviceCommand("deleteKey", ["id": id]) + } + catch (Exception e) { + logDebug("deleteKey failed: ${e.getMessage()}") + } +} + +def deleteKeyByName(String name) { + Map lockCode = atomicState.lockCodesByName[name] + logDebug("deleteKeyByName found key ${lockCode}") + if (lockCode) { + deleteAndRemoveLockCode(lockCode) + } +} + +def deleteAndRemoveLockCode(Map lockCode) { + if (lockCode) { + def newCodes = [] + def newCodesByName = [:] + + newCodes.addAll(atomicState.lockCodes) + newCodesByName.putAll(atomicState.lockCodesByName) + newCodes.remove(lockCode) + newCodesByName.remove(lockCode.name) + atomicState.lockCodes = newCodes + atomicState.lockCodesByName = newCodesByName + deleteKey(lockCode.id.toString()) + } +} + +def readDeviceDetails() +{ + if(!parent) { return } + + devices = parent.readDevices()?.deviceList + details = devices?.find { it.deviceId == parent.getId(device.getDeviceNetworkId()) } + + logDebug("Details=" + details) + + atomicState.lockCodes = [] + atomicState.lockCodesByName = [:] + def expiredCodes = [] + + def newCodes = [] + def newCodesByName = [:] + + logDebug("Details=" + details) + for (it in details?.keyList) { + logDebug("Code=" + it) + Map lockCode = ["id": it.id, "name": it.name, "code": it.password, "iv": it.iv, "status": it.status] + newCodes << lockCode + newCodesByName[it.name] = lockCode + if (it.status == PASSCODE_EXPIRED) + expiredCodes << it.id + } + + atomicState.lockCodes = newCodes + atomicState.lockCodesByName = newCodesByName + + updateCodesAttr() + + logDebug("Found ${expiredCodes.size()} expired codes") + if (!expiredCodes.isEmpty()) { + cleanExpiredCodes(expiredCodes) + } +} + +def updateCodesAttr() { + sendEvent(name: "lockCodes", value: getCodes()) +} + +def cleanExpiredCodes(expiredCodes) { + boolean clean = deleteExpiredCodes == null ? true : deleteExpiredCodes + logDebug("cleanExpiredCodes=${clean} size=${expiredCodes.size}") + if (clean) { + for (code in expiredCodes) { + String codeStr = code.toString() + logDebug("Deleting expired code ${codeStr}") + deleteKey(codeStr) + } + } +} + +def deleteCode(position) { + if (position >= 0 && position < atomicState.lockCodes.size() && atomicState.lockCodes[position]) + deleteAndRemoveLockCode(atomicState.lockCodes[position]) +} + +def setCodeLength() { + // Not supported +} + +def setCode(codeposition, pincode, name) { + deleteCode(codeposition) + createKey(name, pincode) +} + +def getCodes() { + boolean encryptionEnabled=lockCodeEncryption == null ? false : lockCodeEncryption + def codes = [:] + int i = 0 + for (it in atomicState.lockCodes) { + codes[i++] = ["name": it.name, "code": decryptPasscode(it.iv, it.code)] + } + + String str = new JsonBuilder(codes).toString() + return encryptionEnabled ? encrypt(str) : str +} + +def decryptPasscode(String iv, String encrypted) { + if (!parent) return + parent.cacheSecretKey() + String secretKey = parent.getDataValue("secretKey") + try { + byte[] ivBytes = HexUtils.hexStringToByteArray(iv) + def ivSpec = new IvParameterSpec(ivBytes) + def skeySpec = new SecretKeySpec(HexUtils.hexStringToByteArray(secretKey), "AES") + + def cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init(Cipher.DECRYPT_MODE, skeySpec, ivSpec) + byte[] original = cipher.doFinal(encrypted.decodeBase64()) + + return new String(original) + } catch (Exception ex) { + logDebug(ex) + } + + return null +} diff --git a/switchbotMeter b/switchbotMeter index 4faa16e..5e8bea0 100644 --- a/switchbotMeter +++ b/switchbotMeter @@ -18,6 +18,7 @@ limitations under the License. Change history: +0.9.14 - dsegall - Added Battery capability 0.9.13 - tomw - Added Contact Sensor and Motion Sensor support. 0.9.12 - tomw - Added Smart Lock and webhook events support. 0.9.0 - tomw - Initial release. @@ -32,6 +33,7 @@ metadata capability "RelativeHumidityMeasurement" capability "Refresh" capability "TemperatureMeasurement" + capability "Battery" } } @@ -70,5 +72,9 @@ def parse(body) def tempVal = degC ? body.temperature.toDouble() : celsiusToFahrenheit(body.temperature.toDouble()) sendEvent(name: "temperature", value: tempVal?.toDouble()) } + if(null != body.battery) + { + def battery = body.battery.toInteger() + sendEvent(name: "battery", value: battery) + } } - diff --git a/switchbotMotionSensor b/switchbotMotionSensor index 672b5d7..186281a 100644 --- a/switchbotMotionSensor +++ b/switchbotMotionSensor @@ -61,4 +61,3 @@ def parse(body) sendEvent(name: "motion", value: ([true, "DETECTED"].contains(body.moveDetected)) ? "active" : "inactive") } } - diff --git a/switchbotSmartLock b/switchbotSmartLock index 9d590a2..71802ac 100644 --- a/switchbotSmartLock +++ b/switchbotSmartLock @@ -30,6 +30,7 @@ metadata capability "ContactSensor" capability "Lock" capability "Refresh" + capability "Battery" } } @@ -61,6 +62,12 @@ def parse(body) def lState = body.lockState.toLowerCase() sendEvent(name: "lock", value: ["unlocked", "locked", "jammed"].contains(lState) ? lState : "unknown") } + + if(null != body.battery) + { + def battery = body.battery.toInteger() + sendEvent(name: "battery", value: battery) + } } def writeDeviceCommand(command, parameter = "default", commandType = "command") @@ -82,4 +89,3 @@ def unlock() { writeDeviceCommand("unlock") } - diff --git a/switchbotSystem b/switchbotSystem index fd91e3b..605c5a7 100644 --- a/switchbotSystem +++ b/switchbotSystem @@ -18,6 +18,7 @@ limitations under the License. Change history: +0.x.x (05/21 PM) - Bugfixes and new device aliases 0.9.21 - tomw - Added temperature, humidity, and lightLevel support for Hub 2 (in SwitchBot Meter driver). Added Indoor/Outdoor Thermo-Hygrometer (in SwitchBot Meter driver). @@ -48,6 +49,7 @@ metadata command "cacheHttpParams" command "cacheToken" + command "cacheSecretKey" command "parse", ["eventMap"] @@ -103,8 +105,7 @@ def updated() def configure() { - state.clear - + state.clear() initialize() } @@ -151,17 +152,11 @@ def checkCommStatus() } } -def cacheToken() -{ - // workaround to support app that isn't this driver's parent - updateDataValue("token", openToken) -} +def cacheToken() { updateDataValue("token", openToken) } -def cacheHttpParams() -{ - // workaround to support app that isn't this driver's parent - updateDataValue("params", groovy.json.JsonOutput.toJson(genParamsMain(""))) -} +def cacheSecretKey() { updateDataValue("secretKey", secretKey) } + +def cacheHttpParams() { updateDataValue("params", groovy.json.JsonOutput.toJson(genParamsMain(""))) } def parse(Map eventMap) { @@ -219,8 +214,9 @@ def createChildDevices() } if(["Plug Mini (US)", "Plug Mini (JP)"].contains(it.deviceType)) { it.deviceType = "Plug Mini" } - if(it.deviceType == "Color Bulb") { it.deviceType = "Strip Light" } - if(it.deviceType == "Curtain3") { it.deviceType = "Curtain" } + else if(it.deviceType == "Color Bulb") { it.deviceType = "Strip Light" } + else if(it.deviceType == "Curtain3") { it.deviceType = "Curtain" } + else if(it.deviceType.contains("Smart Lock")) { it.deviceType = "Smart Lock" } if((it.deviceId && it.deviceName && it.deviceType) && !findChildDevice(it.deviceId, it.deviceType)) { @@ -294,12 +290,12 @@ def createChildDevice(name, id, deviceType) { try { - def customDevTypes = ["Bot", "Curtain", "Meter", "IR Device", "Humidifier", "Strip Light", "Smart Lock", "Motion Sensor", "Contact Sensor", "Plug Mini", "Blind Tilt"] + def customDevTypes = ["Bot", "Curtain", "Meter", "IR Device", "Humidifier", "Strip Light", "Smart Lock", "Motion Sensor", "Contact Sensor", "Plug Mini", "Blind Tilt", "Smart Lock Pro", "Smart Lock Ultra", "Keypad Touch"] def genericDevTypes = [] deviceType = deviceType.toString() - if(["Hub Mini"].contains(deviceType)) + if(["Hub Mini", "Hub Mini2"].contains(deviceType)) { // unsupported child type, so bail out return @@ -335,113 +331,54 @@ def createChildDevice(name, id, deviceType) } } -def readDevices() +def readEndpoint(ep) { try { - def resp = httpGetExec(genParamsMain("devices"), true) - - if(resp) - { - setDevices(resp?.data?.body) - } + def resp = httpExec("GET", genParamsMain(ep), true) + def val = resp?.data?.body + if(val) { setState(ep, val) } sendEvent(name: "commStatus", value: "good") - } - catch (Exception e) - { - logDebug("readDevices() failed: ${e.message}") - sendEvent(name: "commStatus", value: "error") - throw(e) - } - -} - -def setDevices(devices) -{ - state.devices = devices -} - -def getDevices() -{ - return state.devices -} - -def readScenes() -{ - try - { - def resp = httpGetExec(genParamsMain("scenes"), true) - - if(resp) - { - setScenes(resp?.data?.body) - } - - sendEvent(name: "commStatus", value: "good") + return val } catch (Exception e) { - logDebug("readScenes() failed: ${e.message}") + logDebug("readEndpoint(${ep}) failed: ${e.message}") sendEvent(name: "commStatus", value: "error") throw(e) - } - + } } -def setScenes(scenes) -{ - state.scenes = scenes -} +void setState(name, value) { state[name] = value } +def getState(name) { return state[name] } -def getScenes() -{ - return state.scenes -} +def readDevices() { return readEndpoint("devices") } +void setDevices(devices) { setState("devices", devices) } +def getDevices() { return getState("devices") } + +def readScenes() { return readEndpoint("scenes") } +void setScenes(scenes) { setState("scenes", scenes) } +def getScenes() { return getState("scenes") } def executeScene(sceneId) { - try - { - return httpExec("POST", genParamsMain("scenes/${sceneId}/execute"), true)?.data - } - catch (Exception e) - { - logDebug("executeScene failed: ${e.message}") - throw(e) - } + return httpExec("POST", genParamsMain("scenes/${sceneId}/execute"))?.data } def writeDeviceCommand(id, command, parameter, commandType) { - try - { - def body = [command: command, parameter: parameter, commandType: commandType] - body = groovy.json.JsonOutput.toJson(body) - - return httpExec("POST", genParamsMain("devices/${id}/commands", body), true)?.data - } - catch (Exception e) - { - logDebug("writeDeviceCommand failed: ${e.message}") - throw(e) - } + def body = [command: command, parameter: parameter, commandType: commandType] + body = groovy.json.JsonOutput.toJson(body) + + return httpExec("POST", genParamsMain("devices/${id}/commands", body))?.data } def readDeviceStatus(id) { - try - { - def resp = httpExec("GET", genParamsMain("devices/${id}/status"), true)?.data - return resp - } - catch (Exception e) - { - logDebug("readDeviceStatus failed: ${e.message}") - throw(e) - } + return httpExec("GET", genParamsMain("devices/${id}/status"))?.data } def getBaseURI() @@ -506,85 +443,38 @@ def hmac_sha256(String secretKey, String data) return org.apache.commons.codec.binary.Base64.encodeBase64String(sign) } -def httpGetExec(params, throwToCaller = false) +def httpExec(String operation, Map params, Boolean throwToCaller = false) { - logDebug("httpGetExec(${params})") + def result = null - try - { - def result - httpGet(params) - { resp -> - if (resp) - { - logDebug("resp.data = ${resp.data}") - result = resp - } - } - return result - } - catch (Exception e) - { - logDebug("httpGetExec() failed: ${e.message}") - //logDebug("status = ${e.getResponse().getStatus().toInteger()}") - if(throwToCaller) - { - throw(e) - } - } -} - -def httpPostExec(params, throwToCaller = false) -{ - logDebug("httpPostExec(${params})") + logDebug("httpExec(${operation}, ${params})") - try - { - def result - httpPost(params) - { resp -> - if (resp) - { - logDebug("resp.data = ${resp.data}") - result = resp - } - } - return result + def httpClosure = + { resp -> + result = resp + //logDebug("result.data = ${result.data}") } - catch (Exception e) - { - logDebug("httpPostExec() failed: ${e.message}") - //logDebug("status = ${e.getResponse().getStatus().toInteger()}") - if(throwToCaller) - { - throw(e) - } - } -} - -def httpExec(operation, params, throwToCaller = false) -{ - def res + + def httpOp switch(operation) { - default: - logDebug("unsupported Http operation") - - if(throwToCaller) - { - throw new Exception("unsupported Http operation") - } - break - case "POST": - res = httpPostExec(params, throwToCaller) + httpOp = this.delegate.&httpPost break - case "GET": - res = httpGetExec(params, throwToCaller) + httpOp = this.delegate.&httpGet break } - return res + try + { + httpOp(params, httpClosure) + return result + } + catch (Exception e) + { + logDebug("httpExec(${operation}, ${params}) failed: ${e}") + if(throwToCaller) { throw(e) } + } }