TS0202 RH3040 Tuya ZigBee Motion Sensor.groovy
All credit to Mikhail Diatchenko's good efforts to get this working.
/**
* Konke ZigBee Motion Sensor
* Device Driver for Hubitat Elevation hub
* Version 1.0.2
*
* Based on code from Robert Morris and ssalahi.
*
* 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.
*
*/
// https://community.hubitat.com/t/konke-motion-sensor/21240/3
import hubitat.zigbee.clusters.iaszone.ZoneStatus
import com.hubitat.zigbee.DataType
metadata {
definition (name: "TS0202 RH3040 Tuya ZigBee Motion Sensor", namespace: "muxa", author: "Mikhail Diatchenko") {
capability "MotionSensor"
capability "Battery"
// capability "PushableButton"
capability "Configuration"
capability "Refresh"
capability "Sensor"
capability "Motion Sensor"
command "active"
command "inactive"
fingerprint endpointId: "01", profileId: "0104", inClusters: "0000,0001,0003,0500", outClusters: "0000,0001,0003,0500", manufacturer: "_TYZB01_dl7cejts", model: "TS0202", application:"21"
fingerprint endpointId: "01", profileId: "0104", inClusters: "0001,0500,0003,0000", outClusters: "1000,0006,0019,000A", manufacturer: "_TZ3000_mmtwjmaq", model: "TS0202", application:"43"
}
preferences {
input "motionreset", "number", title: "After motion is detected, wait ___ second(s) until resetting to inactive state. Default = 16 seconds (Hardware resets at 15 seconds)", description: "", range: "1..7200", defaultValue: 16
input "batteryReportingHours", "number", title: "Report battery every ___ hours. Default = 12h (Minimum 2 h)", description: "", range: "2..12", defaultValue: 12
input name: "infoLogging", type: "bool", title: "Enable info message logging", description: ""
input name: "debugLogging", type: "bool", title: "Enable debug message logging", description: ""
}
}
// Parse incoming device messages to generate events
def parse(String description) {
Map map = [:]
// logDebug("Parsing: $description")
if (description?.startsWith('zone status')) {
logDebug("Zone status: $description")
def zs = zigbee.parseZoneStatus(description)
map = parseIasMessage(zs)
}
else if (description?.startsWith("catchall") || description?.startsWith("read attr"))
{
Map descMap = zigbee.parseDescriptionAsMap(description)
if (descMap.clusterInt == zigbee.POWER_CONFIGURATION_CLUSTER && descMap.attrInt == 0x0020) {
map = parseBattery(descMap.value)
} else if (descMap.command == "07") {
// Process "Configure Reporting" response
if (descMap.data[0] == "00") {
switch (descMap.clusterInt) {
case zigbee.POWER_CONFIGURATION_CLUSTER:
logInfo("Battery reporting configured");
break
default:
log.warn("Unknown reporting configured: ${descMap}");
break
}
} else {
log.warn "Reporting configuration failed: ${descMap}"
}
} else if (descMap.clusterInt == 0x0500 && descMap.attrInt == 0x0002) {
logDebug("Zone status repoted: $descMap")
def zs = new ZoneStatus(Integer.parseInt(descMap.value, 16))
map = parseIasMessage(zs)
} else if (descMap.clusterInt == 0x0500 && descMap.attrInt == 0x0011) {
logInfo("IAS Zone ID: ${descMap.value}")
} else if (descMap.profileId == "0000") {
// ignore routing table messages
} else {
log.warn ("Description map not parsed: $descMap")
}
}
//------IAS Zone Enroll request------//
else if (description?.startsWith('enroll request')) {
logInfo "Sending IAS enroll response..."
return zigbee.enrollResponse()
}
else {
log.warn "Description not parsed: $description"
}
if (map != [:]) {
//logInfo(map.descriptionText)
logInfo(description)
return createEvent(map)
} else
return [:]
}
// helpers -------------------
def parseIasMessage(ZoneStatus zs) {
if ((zs.alarm1 || zs.alarm2) && zs.battery == 0 && zs.trouble == 0) {
// Motion detected
return handleMotion(true)
}
else if (zs.tamper == 1 && zs.battery == 1 && zs.trouble == 1 && zs.ac == 1) {
logDebug "Device button pressed"
map = [
name: 'pushed',
value: 1,
isStateChange: true,
descriptionText: "Device button pushed"
]
}
else {
// log.warn "Zone status message not parsed"
// if (debugLogging) {
// logDebug "zs.alarm1 = $zs.alarm1"
// logDebug "zs.alarm2 = $zs.alarm2"
// logDebug "zs.tamper = $zs.tamper"
// logDebug "zs.battery = $zs.battery"
// logDebug "zs.supervisionReports = $zs.supervisionReports"
// logDebug "zs.restoreReports = $zs.restoreReports"
// logDebug "zs.trouble = $zs.trouble"
// logDebug "zs.ac = $zs.ac"
// logDebug "zs.test = $zs.test"
// logDebug "zs.batteryDefect = $zs.batteryDefect"
// }
}
}
private handleMotion(motionActive) {
if (motionActive) {
def timeout = motionreset ?: 16
// The sensor only sends a motion detected message so reset to motion inactive is performed in code
runIn(timeout, resetToMotionInactive)
if (device.currentState('motion')?.value != "active") {
state.motionStarted = now()
}
}
return getMotionResult(motionActive)
}
def getMotionResult(motionActive) {
def descriptionText = "Detected motion"
if (!motionActive) {
descriptionText = "Motion reset to inactive after ${getSecondsInactive()}s"
}
return [
name : 'motion',
value : motionActive ? 'active' : 'inactive',
descriptionText : descriptionText
]
}
def resetToMotionInactive() {
if (device.currentState('motion')?.value == "active") {
def descText = "Motion reset to inactive after ${getSecondsInactive()}s"
sendEvent(
name:'motion',
value:'inactive',
// isStateChange: true,
descriptionText: descText
)
logInfo(descText)
}
}
def getSecondsInactive() {
if (state.motionStarted) {
return Math.round((now() - state.motionStarted)/1000)
} else {
return motionreset ?: 16
}
}
def active() {
sendEvent(name: "motion", value: "active")
runIn(12, off)
}
def inactive() {
sendEvent(name: "motion", value: "inactive")
}
// Convert 2-byte hex string to voltage
// 0x0020 BatteryVoltage - The BatteryVoltage attribute is 8 bits in length and specifies the current actual (measured) battery voltage, in units of 100mV.
private parseBattery(valueHex) {
//logDebug("Battery parse string = ${valueHex}")
def rawVolts = Integer.parseInt(valueHex, 16) / 10 // hexStrToSignedInt(valueHex)/10
def minVolts = voltsmin ? voltsmin : 2.5
def maxVolts = voltsmax ? voltsmax : 3.0
def pct = (rawVolts - minVolts) / (maxVolts - minVolts)
def roundedPct = Math.min(100, Math.round(pct * 100))
def descText = "Battery level is ${roundedPct}% (${rawVolts} Volts)"
//logInfo(descText)
// sendEvent(name: "batteryLevelLastReceived", value: new Date())
def result = [
name: 'battery',
value: roundedPct,
unit: "%",
//isStateChange: true,
descriptionText: descText
]
return result
}
// lifecycle methods -------------
// installed() runs just after a sensor is paired
def installed() {
logInfo("Installing")
// sendEvent(name: "numberOfButtons", value: 1, descriptionText: "Device installed")
return refresh()
}
// configure() runs after installed() when a sensor is paired or reconnected
def configure() {
logInfo("Configuring")
return configureReporting()
}
def refresh() {
logInfo("Refreshing")
return zigbee.readAttribute(zigbee.POWER_CONFIGURATION_CLUSTER, 0x0020) // battery voltage
}
// updated() runs every time user saves preferences
def updated() {
logInfo("Updating preference settings")
return configureReporting()
}
// helpers -------------
private def configureReporting() {
def seconds = Math.round((batteryReportingHours ?: 12)*3600)
logInfo("Battery reporting frequency: ${seconds/3600}h")
return zigbee.configureReporting(zigbee.POWER_CONFIGURATION_CLUSTER, 0x0020, DataType.UINT8, seconds, seconds, 0x01)
+ zigbee.readAttribute(zigbee.POWER_CONFIGURATION_CLUSTER, 0x20)
}
private def logDebug(message) {
if (debugLogging) log.debug "${device.displayName}: ${message}"
}
private def logInfo(message) {
if (infoLogging)
log.info "${device.displayName}: ${message}"
}