/* * Linptech / Moes 24Ghz Presence Sensor ES1 driver 2.0 * * Driver made possible by the work of Krassimir Kossev * Code pulled from methods developed by "Krassimir Kossev" in the Tuya 4in1 driver * https://raw.githubusercontent.com/kkossev/Hubitat/development/Drivers/Tuya%20Multi%20Sensor%204%20In%201/Tuya%20Multi%20Sensor%204%20In%201.groovy * * 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: * * V1.0.0 09-26-2023 Modifying Tuya 4in1 driver by "Krassimir Kossev" to support only Linptech/Moes 24Ghz Presence Sensor ES1 * V1.1.0 09-27-2023 Fixed lux reporting and parsing, clean up / todo - distance reporting option * V1.2.0 09-30-2023 Added distance reporting * V1.3.0 10-01-2023 Added fade time option and states for preferences * V1.4.0 10-03-2023 Addjust fade time range to match Tuya hub settings * V1.5.0 02-03-2024 Added addtional info logging * V1.6.0 02-14-2024 Added fade Time and existance time attributes * V1.7.0 02-18-2024 Changed commands to replace preference settings, added actuator capability * V1.8.0 02-19-2024 Added Device Health Check (testing) * V1.9.0 02-20-2024 Fix for existance time = 1 and changed attribute to number * V2.0.0 02-22-2024 Changed health check method for lower hub resource usage, code cleanup/ bug fixes * V2.0.1 02-25-2024 Bug fix for returned fade time values over 255. */ def driverVer() { return "2.0" } import hubitat.zigbee.clusters.iaszone.ZoneStatus metadata { definition(name: "Linptech 24Ghz Presence Sensor ES1", namespace: "Gassgs", author: "Krassimir Kossev", importUrl: "https://raw.githubusercontent.com/Gassgs/Hubitat-Apps-and-Drivers/master/Drivers/Linptech%2024Ghz%20Presence%20Sensor%20ES1.groovy", singleThreaded: true ) { capability "Motion Sensor" capability "IlluminanceMeasurement" capability "Actuator" capability "Configuration" capability "Refresh" capability "Sensor" command "setMotionSensitivity", [[name:"Set Motion Sensitivity", type: "ENUM",description: "Motion Detection Sensitivity", constraints: ["low","medium-low","medium","medium-high","high"],defaultValue: "high"]] command "setStaticSensitivity", [[name:"Set Static Sensitivity", type: "ENUM",description: "Static Detection Sensitivity", constraints: ["low","medium-low","medium","medium-high","high"],defaultValue: "high"]] command "setDetectionDistance", [[name:"Set Detection Distance", type: "ENUM",description: "Detection Distance in Meters", constraints: [1.5,2.25,3.0,3.75,4.5,5.25,6.0],defaultValue: 6.0]] command "setFadeTime", [[name:"Set Fade Time", type: "NUMBER",description: "Fade Timeout in Seconds", constraints: "0..10000",defaultValue: "10"]] attribute "distance", "number" attribute "motionSensitivity", "string" attribute "staticSensitivity", "string" attribute "detectionDistance", "string" attribute "existanceTime", "number" attribute "fadeTime", "number" attribute "healthStatus", "string" fingerprint inClusters: "0000,0003,0004,0005,E002,4000,EF00,0500", outClusters: "0019,000A", manufacturer: "_TZ3218_awarhusb", model: "TS0225", deviceJoinName: "LINPTECH 24Ghz Human Presence Detector" } preferences{ section{ input "luxThreshold", "number", title: "Lux threshold", description: "Range (0..999)", range: "0..999", defaultValue: 5 input "enableDistance", "bool", title: "Enable Distance Reporting?", defaultValue: false, required: false, multiple: false input "healthCheckEnabled", "bool", title: "Enable Health Check?", defaultValue: false, required: false if(healthCheckEnabled){ def pingRate = [:] pingRate << ["5 min" : "5 minutes"] pingRate << ["10 min" : "10 minutes"] pingRate << ["15 min" : "15 minutes"] pingRate << ["30 min" : "30 minutes"] pingRate << ["60 min" : "60 minutes"] input("healthCheckInterval", "enum", title: "Health Check Interval",options: pingRate, defaultValue: "30 min", required: true ) } input "enableInfo", "bool", title: "Enable info logging?", defaultValue: true, required: false, multiple: false input "enableDebug", "bool", title: "Enable debug logging?", defaultValue: false, required: false, multiple: false } } } Map parseDescriptionAsMap( String description ) { def descMap = [:] try { descMap = zigbee.parseDescriptionAsMap(description) return descMap } catch (e1) { logDebug "exception ${e1} caught while processing parseDescriptionAsMap myParseDescriptionAsMap description: ${description}" descMap = [:] try { descMap += description.replaceAll('\\[|\\]', '').split(',').collectEntries { entry -> def pair = entry.split(':') [(pair.first().trim()): pair.last().trim()] } if (descMap.value != null) { descMap.value = zigbee.swapOctets(descMap.value) } } catch (e2) { logWarn "exception ${e2} caught while parsing using an alternative method myParseDescriptionAsMap description: ${description}" return [:] } logDebug "alternative method parsing success: descMap=${descMap}" } return descMap } def parse(String description) { Map descMap = [:] logDebug "parse: zone status: $description" if (description?.startsWith('zone status')){ logDebug "parse: zone status: $description" parseIasMessage(description) } else if (description?.startsWith('read attr -')){ try { descMap = parseDescriptionAsMap(description) } catch (e) { logWarn "exception caught while processing description ${description}" return } if (descMap.cluster == "0400" && descMap.attrId == "0000") { def rawLux = Integer.parseInt(descMap.value,16) illuminanceEvent( rawLux ) } else if (descMap.cluster == "E002" && descMap.attrId == "E00A") { if (enableDistance){ processDistance( descMap ) } } else if (descMap.cluster == "E002" && descMap.attrId == "E001") { existanceTime( descMap ) } else if (descMap.cluster == "E002" && descMap.attrId == "E004") { motionSensitivity( descMap ) } else if (descMap.cluster == "E002" && descMap.attrId == "E005") { staticSensitivity( descMap ) } else if (descMap.cluster == "E002" && descMap.attrId == "E00B") { distanceLimit( descMap ) } } else if (description?.startsWith('catchall')){ try { descMap = parseDescriptionAsMap(description) logDebug "$descMap" } catch (e) { logWarn "exception caught while processing description ${description}" return } if (descMap.command == "06") { fadeTime( descMap ) } } if (healthCheckEnabled) { if (!state.healthCheck){ unschedule(healthExpired) logInfo ("$device.label Online") state.healthCheck = true } if (device.currentValue("healthStatus") != "online"){ sendEvent(name: "healthStatus", value: "online") } } } def healthCheck() { state.healthCheck = false runIn(30,healthExpired) runIn(1,healthPing) } def healthPing() { val = device.currentValue("detectionDistance") setDetectionDistance( val ) } def healthExpired() { sendEvent(name: "healthStatus", value: "offline") logError "$device.label - Offline" } private Map parseIasMessage(String description) { ZoneStatus zs = zigbee.parseZoneStatus(description) translateZoneStatus(zs) } private Map translateZoneStatus(ZoneStatus zs) { return (zs.isAlarm1Set() || zs.isAlarm2Set()) ? getMotionResult('active') : getMotionResult('inactive') } private Map getMotionResult(value){ status = device.currentValue("motion") if (value == "active"){ if (status != "active"){ sendEvent(name:"motion",value:"active") logInfo "$device.label Motion Active" } }else{ if (status != "inactive"){ sendEvent(name:"motion",value:"inactive") logInfo "$device.label Motion Inactive" } } } def illuminanceEvent( rawLux ) { def lux = rawLux > 0 ? Math.round(Math.pow(10,(rawLux/10000))) : 0 illuminanceEventLux( lux as Integer) } def illuminanceEventLux( lux ) { Integer illumCorrected = Math.round((lux * ((settings?.illuminanceCoeff ?: 1.00) as float))) Integer delta = Math.abs(safeToInt(device.currentValue("illuminance")) - (illumCorrected as int)) if (device.currentValue("illuminance", true) == null || (delta >= safeToInt(settings?.luxThreshold))) { sendEvent("name": "illuminance", "value": illumCorrected, "unit": "lx", "type": "physical", "descriptionText": "Illuminance is ${lux} Lux") logInfo "$device.label Illuminance is ${illumCorrected} Lux" } else { logDebug "ignored illuminance event ${illumCorrected} lx : the change of ${delta} lx is less than the ${safeToInt(settings?.luxThreshold)} lux threshold!" } } def motionSensitivity( descMap ) { def value = zigbee.convertHexToInt(descMap.value) logDebug "Cluster ${descMap.cluster} Attribute ${descMap.attrId} value is ${value} (0x${descMap.value})" if (value == 1){motionValue = "low"} else if (value == 2){motionValue = "medium-low"} else if (value == 3){motionValue = "medium"} else if (value == 4){motionValue = "medium-high"} else if (value == 5){motionValue = "high"} else{motionValue = "unknown"} logInfo "$device.label Motion Sensitivity - $motionValue" sendEvent(name: "motionSensitivity",value:"$motionValue") } def staticSensitivity( descMap ) { def value = zigbee.convertHexToInt(descMap.value) logDebug "Cluster ${descMap.cluster} Attribute ${descMap.attrId} value is ${value} (0x${descMap.value})" if (value == 1){staticValue = "low"} else if (value == 2){staticValue = "medium-low"} else if (value == 3){staticValue = "medium"} else if (value == 4){staticValue = "medium-high"} else if (value == 5){staticValue = "high"} else{staticValue = "unknown"} logInfo "$device.label Static Sensitivity - $staticValue" sendEvent(name: "staticSensitivity",value:"$staticValue") } def distanceLimit( descMap ) { def value = zigbee.convertHexToInt(descMap.value) logDebug "Cluster ${descMap.cluster} Attribute ${descMap.attrId} value is ${value} (0x${descMap.value})" distanceValue = (value/100) newDistance = distanceValue as String currentDistance = device.currentValue("detectionDistance") if (newDistance != currentDistance){ logInfo "$device.label Detection Distance - $distanceValue meters" sendEvent(name: "detectionDistance",value:"$distanceValue") } } def processDistance( descMap ) { def value = zigbee.convertHexToInt(descMap.value) logDebug "Cluster ${descMap.cluster} Attribute ${descMap.attrId} value is ${value} (0x${descMap.value})" logInfo "$device.label distance is ${value/100} m" sendEvent(name : "distance", value : value/100, unit : "m") } def existanceTime( descMap ){ currentExistanceTime = device.currentValue("existanceTime") def value = zigbee.convertHexToInt(descMap.value) if (value as Number != currentExistanceTime){ logInfo "$device.label Existance Time - $value minutes" sendEvent(name : "existanceTime", value : "$value") } } def fadeTime( descMap ) { def value = zigbee.convertHexToInt(descMap?.data[8]+descMap?.data[9]) logInfo "$device.label Fade Time - $value seconds" sendEvent(name : "fadeTime", value : "$value") } def updatePreferences(){ ArrayList cmds = [] cmds += tuyaBlackMagic() if (device.currentValue("fadeTime") == null) {cmds += setFadeTime(10)} if (device.currentValue("detectionDistance") == null) {cmds += setDetectionDistance(6.0)} if (device.currentValue("motionSensitivity") == null) {cmds += setMotionSensitivity( "high" )} if (device.currentValue("staticSensitivity") == null) {cmds += setStaticSensitivity("high")} if (cmds != null) { logDebug "$device.label sending the changed AdvancedOptions" sendZigbeeCommands( cmds ) } } def tuyaBlackMagic() { List cmds = [] cmds += zigbee.readAttribute(0x0000, [0x0004, 0x000, 0x0001, 0x0005, 0x0007, 0xfffe], [:], delay=200) cmds += zigbee.writeAttribute(0x0000, 0xffde, 0x20, 0x13, [:], delay=200) return cmds } def setDetectionDistance( data ) { def val = data as float def value = Math.round(val * 100) logDebug "$device.label set MotionDetectionDistance to ${val}m (raw ${value})" return zigbee.writeAttribute(0xE002, 0xE00B, 0x20, value as int, [:], delay=200) } def setMotionSensitivity( data ) { if (data == "low"){val = 1} else if (data == "medium-low"){val = 2} else if (data == "medium"){val = 3} else if (data == "medium-high"){val = 4} else if (data == "high"){val = 5} def value = val as int logDebug "$device.label set MotionDetectionSensitivity to ${value}" return zigbee.writeAttribute(0xE002, 0xE004, 0x20, value as int, [:], delay=200) } def setStaticSensitivity( data ) { if (data == "low"){val = 1} else if (data == "medium-low"){val = 2} else if (data == "medium"){val = 3} else if (data == "medium-high"){val = 4} else if (data == "high"){val = 5} def value = val as int logDebug "$device.label set StaticDetectionSensitivity to ${value}" return zigbee.writeAttribute(0xE002, 0xE005, 0x20, value as int, [:], delay=200) } def setFadeTime( val ){ def value = val as int logDebug "$device.label set fade time to ${value} seconds" return sendTuyaCommand( "65","02", zigbee.convertToHexString(value, 8)) } private sendTuyaCommand(dp, dp_type, fncmd) { ArrayList cmds = [] int tuyaCmd = 0x04 cmds += zigbee.command(0xEF00, tuyaCmd, PACKET_ID + dp + dp_type + zigbee.convertToHexString((int)(fncmd.length()/2), 4) + fncmd ) logDebug "${device.displayName} sendTuyaCommand = ${cmds}" return cmds } private getPACKET_ID() { state.packetID = ((state.packetID ?: 0) + 1 ) % 65536 return zigbee.convertToHexString(state.packetID, 4) } Integer safeToInt(val, Integer defaultVal=0) { return "${val}"?.isInteger() ? "${val}".toInteger() : defaultVal } Double safeToDouble(val, Double defaultVal=0.0) { return "${val}"?.isDouble() ? "${val}".toDouble() : defaultVal } void sendZigbeeCommands(ArrayList cmd) { logDebug "sendZigbeeCommands (cmd=$cmd)" hubitat.device.HubMultiAction allActions = new hubitat.device.HubMultiAction() cmd.each { allActions.add(new hubitat.device.HubAction(it, hubitat.device.Protocol.ZIGBEE)) } sendHubCommand(allActions) } def configure() { logDebug "Configuring Reporting..." ArrayList cmds = [] cmds += "delay 200" cmds += "zdo bind 0x${device.deviceNetworkId} 0x02 0x01 0x0402 {${device.zigbeeId}} {}" cmds += "delay 200" cmds += "zdo bind 0x${device.deviceNetworkId} 0x02 0x01 0x0405 {${device.zigbeeId}} {}" cmds += "delay 200" cmds += "zdo bind 0x${device.deviceNetworkId} 0x03 0x01 0x0400 {${device.zigbeeId}} {}" sendZigbeeCommands(cmds) } def installed(){ configure() updatePreferences() } def updated(){ initialize() configure() updatePreferences() } def initialize(){ if (enableDebug){ logInfo "Verbose logging has been enabled for the next 30 minutes." runIn(1800, logsOff) } state.DriverVersion=driverVer() if (!enableDistance){ device.deleteCurrentState('distance') } if (healthCheckEnabled){ switch(healthCheckInterval) { case "5 min" : runEvery5Minutes(healthCheck) logDebug "Health Check every 5 minutes schedule" logInfo "$device.label Health Check every 5 minutes schedule" break case "10 min" : runEvery10Minutes(healthCheck) logDebug "Health Check every 10 minutes schedule" logInfo "$device.label Health Check every 10 minutes schedule" break case "15 min" : runEvery15Minutes(healthCheck) logDebug "Health Check every 15 minutes schedule" logInfo "$device.label Health Check every 15 minutes schedule" break case "30 min" : runEvery30Minutes(healthCheck) logDebug "Health Check every 30 minutes schedule" logInfo "$device.label Health Check every 30 minutes schedule" break case "60 min" : runEvery1Hour(healthCheck) logDebug "Health Check every 60 minutes schedule" logInfo "$device.label Health Check every 60 minutes schedule" break } } if (!healthCheckEnabled){ device.deleteCurrentState('healthStatus') unschedule(healthCheck) state.healthCheck = false } } def refresh() { logInfo "$device.label - Refreshing Values" if (healthCheckEnabled){ sendEvent(name: "healthStatus", value: "checking") healthCheck() } ArrayList cmds = [] IAS_ATTRIBUTES.each { key, value -> cmds += zigbee.readAttribute(0x0500, key, [:], delay=200) cmds += zigbee.command(0xEF00, 0x03) } sendZigbeeCommands( cmds ) } private logError(msgOut){ log.error msgOut } private logWarn(msgOut){ log.warn msgOut } private logDebug(msgOut){ if (settings.enableDebug){ log.debug msgOut } } private logInfo(msgOut){ if (settings.enableInfo){ log.info msgOut } } def logsOff(){ logWarn "debug logging disabled..." device.updateSetting("enableDebug", [value:"false",type:"bool"]) }