/* groovylint-disable CompileStatic */ /** * Tuya ZigBee Vibration Sensor * Device Driver for Hubitat Elevation hub * * https://community.hubitat.com/t/tuya-vibration-sensor/75269 * * Based on Mikhail Diatchenko (muxa) 'Konke ZigBee Motion Sensor' 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. * * ver 1.0.3 2022-02-28 kkossev - inital version * ver 1.0.4 2022-03-02 kkossev - 'acceleration' misspelled bug fix * ver 1.0.5 2022-03-03 kkossev - Battery reporting * ver 1.0.6 2022-03-03 kkossev - Vibration Sensitivity * ver 1.0.7 2022-05-12 kkossev - TS0210 _TYZB01_pbgpvhgx Smart Vibration Sensor HS1VS * ver 1.0.8 2022-11-08 kkossev - TS0210 _TZ3000_bmfw9ykl * ver 1.1.0 2023-03-07 kkossev - added Import URL; IAS enroll response is sent w/ 1 second delay; added _TYZB01_cc3jzhlj ; IAS is initialized on configure(); * ver 1.2.0 2024-05-20 kkossev - add healthStatus and ping(); bug fixes; added ThirdReality 3RVS01031Z ; added capability and preference 'ThreeAxis'; added Samsung multisensor; logsOff scheduler; added sensitivity attribute, * ver 1.2.1 2024-05-22 kkossev - delete scheduled jobs on Save Preferences; added lastBattery attribute; added setAccelarationInactive command; * ver 1.2.2 2024-06-03 kkossev - sensitivity preference is hidden for non-Tuya models; threeAxis preference is hidden for Tuya models; * ver 1.3.0 2025-01-28 kkossev - added Tuya Cluster parser; added TS0601 _TZE200_kzm5w4iz (contact&vibration); added TS0601 _TZE200_iba1ckek (Tilt Xyz Axis Sensor) (ZG-103Z); added queryAllTuyaDP(); missing [overwrite: true] bug fix; * ver 1.3.1 2025-02-19 kkossev - added TS0210 _TZ3000_lqpt3mvr _TZ3000_lzdjjfss _TYZB01_geigpsy4 * ver 1.4.0 2025-03-01 kkossev - added ShockSensor capability; added shockSensor option (default:enabled) * ver 1.4.1 2025-08-30 kkossev - added TS0210 _TZ3210100000_5oy7cysk for tests @masachapa34 * ver 1.4.2 2026-02-04 kkossev - added TS0210 _TZ32101000000_5oy7cysk (alternative variant); added _TZE200_hggxgsjj _TZE200_yjryxpot _TZE200_afycb3cg (ZG-103Z variants); added Tuya sensitivity setting for some models; * * TODO: save the configuration commands in a state and send them on device wakes up * TODO: this driver does not process ZCL battery percentage reports, only voltage reports! * TODO: bugFix: healthCheck is not started on installed() * TODO: add powerSource attribute * TODO: make sensitivity range dependant on the device model * TODO: minimum time filter : https://community.hubitat.com/t/tuya-vibration-sensor-better-laundry-monitor/113296/9?u=kkossev * TODO: add capability.tamperAlert * TODO: handle tamper: (zoneStatus & 1<<2); handle battery_low: (zoneStatus & 1<<3); TODO: check const sens = {'high': 0, 'medium': 2, 'low': 6}[value]; */ static String version() { "1.4.2" } static String timeStamp() { "2026/02/04 7:32 AM" } import groovy.transform.Field import hubitat.zigbee.clusters.iaszone.ZoneStatus import com.hubitat.zigbee.DataType import groovy.transform.CompileStatic metadata { definition (name: "Tuya ZigBee Vibration Sensor", namespace: "kkossev", author: "Krassimir Kossev", importUrl: "https://raw.githubusercontent.com/kkossev/Hubitat/development/Drivers/Tuya%20ZigBee%20Vibration%20Sensor/Tuya%20ZigBee%20Vibration%20Sensor.groovy", singleThreaded: true ) { capability "Sensor" capability "AccelerationSensor" capability "ShockSensor" // shock - ENUM ["clear", "detected"] attribute //capability "TamperAlert" // tamper - ENUM ["clear", "detected"] capability "Battery" capability "Configuration" capability "Refresh" capability 'Health Check' capability 'ThreeAxis' // Attributes: threeAxis - VECTOR3 command 'setAccelarationInactive', [[name: 'Reset the accelaration to inactive state']] attribute "batteryVoltage", "number" attribute 'healthStatus', 'enum', ['unknown', 'offline', 'online'] attribute 'rtt', 'number' attribute 'batteryStatus', 'enum', ["normal", "replace"] attribute 'sensitivity', 'number' attribute 'tuyaSensitivity', 'enum', ['low', 'middle', 'high'] attribute 'lastBattery', 'date' // last battery event time - added in 1.2.1 05/21/2024 attribute 'tilt', 'enum', ["clear", "detected"] fingerprint profileId:"0104", endpointId:"01", inClusters:"0000,000A,0001,0500", outClusters:"0019", model:"TS0210", manufacturer:"_TYZB01_3zv6oleo" // KK fingerprint profileId:"0104", endpointId:"01", inClusters:"0000,000A,0001,0500", outClusters:"0019", model:"TS0210", manufacturer:"_TYZB01_kulduhbj" // not tested https://fr.aliexpress.com/item/1005002490419821.html fingerprint profileId:"0104", endpointId:"01", inClusters:"0000,000A,0001,0500", outClusters:"0019", model:"TS0210", manufacturer:"_TYZB01_cc3jzhlj" // not tested fingerprint profileId:"0104", endpointId:"01", inClusters:"0000,000A,0001,0500", outClusters:"0019", model:"TS0210", manufacturer:"_TYZB01_geigpsy4" // not tested fingerprint profileId:"0104", endpointId:"01", inClusters:"0000,000A,0001,0500", outClusters:"0019", model:"TS0210", manufacturer:"_TZ3000_lqpt3mvr" // https://community.hubitat.com/t/release-tuya-zigbee-vibration-sensor/138208/37?u=kkossev fingerprint profileId:"0104", endpointId:"01", inClusters:"0000,0001,0003,0020,0500,0B05", outClusters:"0019", model:"TS0210", manufacturer:"_TYZB01_pbgpvhgx" // Smart Vibration Sensor HS1VS fingerprint profileId:"0104", endpointId:"01", inClusters:"0001,0500,0000", outClusters:"0019,000A", model:"TS0210", manufacturer:"_TZ3000_bmfw9ykl" // Moes https://community.hubitat.com/t/vibration-sensor/85203/14?u=kkossev fingerprint profileId:"0104", endpointId:"01", inClusters:"0001,0500,0000", outClusters:"0019,000A", model:"TS0210", manufacturer:"_TZ3000_lzdjjfss" // not tested fingerprint profileId:"0104", endpointId:"01", inClusters:"0001,0500,0000", outClusters:"0019,000A", model:"TS0210", manufacturer:"_TYZB01_j9xxahcl" // not tested fingerprint profileId:"0104", endpointId:"01", inClusters:"0001,0500,0000", outClusters:"0019,000A", model:"TS0210", manufacturer:"_TZ3000_fkxmyics" // https://community.hubitat.com/t/vibration-sensor-sensitivity-adjustment/93930/26?u=kkossev fingerprint profileId:"0104", endpointId:"01", inClusters:"0000,0001,0500,FFF1", outClusters:"0019", model:"3RVS01031Z", manufacturer:"Third Reality, Inc" // Third Reality vibration sensor fingerprint profileId:"0104", endpointId:"01", inClusters:"0000,0001,0003,0020,0402,0500,0B05,FC02", outClusters:"0003,0019", model:"multi", manufacturer:"Samjin" // Samsung Multisensor fingerprint profileId:"0104", endpointId:"01", inClusters:"0004,0005,EF00,0000", outClusters:"0019,000A", model:"TS0601", manufacturer:"_TZE200_kzm5w4iz" // https://github.com/flatsiedatsie/zigbee-herdsman-converters/blob/ef4d559ccba0a39cd6957d2270352e29fb1d0296/converters/fromZigbee.js#L7449-L7467 fingerprint profileId:"0104", endpointId:"01", inClusters:"0000,0003,0500,0001", outClusters:"0019,000A", model:"TS0601", manufacturer:"_TZE200_iba1ckek" // https://nl.aliexpress.com/item/1005007520278259.html Tilt Xyz Axis Sensor (ZG-103Z) fingerprint profileId:"0104", endpointId:"01", inClusters:"0000,0003,0500,0001", outClusters:"0019,000A", model:"TS0601", manufacturer:"_TZE200_hggxgsjj" // ZG-103Z variant fingerprint profileId:"0104", endpointId:"01", inClusters:"0000,0003,0500,0001", outClusters:"0019,000A", model:"TS0601", manufacturer:"_TZE200_yjryxpot" // ZG-103Z variant fingerprint profileId:"0104", endpointId:"01", inClusters:"0000,0003,0500,0001", outClusters:"0019,000A", model:"TS0601", manufacturer:"_TZE200_afycb3cg" // ZG-103Z variant fingerprint profileId:"0104", endpointId:"01", inClusters:"0000,0003,0500,0001", outClusters:"0019,000A", model:"TS0601", manufacturer:"_TZ3210100000_5oy7cysk" // ZG-103Z family variant (reported by Hubitat) fingerprint profileId:"0104", endpointId:"01", inClusters:"0000,0003,0500,0001", outClusters:"0019,000A", model:"TS0601", manufacturer:"_TZ32101000000_5oy7cysk" // ZG-103Z family variant (alt ID) fingerprint profileId:"0104", endpointId:"01", inClusters:"0000,E000,0003,0001,0500,E002,EF00", outClusters:"000A,0019", manufacturer:"_TZ3210100000_5oy7cysk", model: "TS0210" // @masachapa34 fingerprint profileId:"0104", endpointId:"01", inClusters:"0000,E000,0003,0001,0500,E002,EF00", outClusters:"000A,0019", model:"TS0210", manufacturer:"_TZ32101000000_5oy7cysk" // https://community.hubitat.com/t/release-tuya-zigbee-vibration-sensor/138208/70?u=kkossev } preferences { input name: "txtEnable", type: "bool", title: "Enable info message logging", description: "" input name: "logEnable", type: "bool", title: "Enable debug message logging", description: "" if (device && supportsIasSensitivity()) { input name: "sensitivity", type: "enum", title: "Vibration Sensitivity", description: "Select Vibration Sensitivity", defaultValue: "3", options:["0":"0 - Maximum", "1":"1", "2":"2", "3":"3 - Medium", "4":"4", "5":"5", "6":"6 - Minimum"] } if (device && supportsTuyaSensitivity()) { input name: 'tuyaSensitivity', type: 'enum', title: 'Tuya Sensitivity', description: 'Vibration detection sensitivity (ZG-103Z family)', defaultValue: TuyaSensitivityOpts.defaultValue, options: TuyaSensitivityOpts.options } input "vibrationReset", "number", title: "After vibration is detected, wait $vibrationReset second(s) until resetting to inactive state. Default = $VIBRATION_RESET seconds.", description: "", range: "1..7200", defaultValue: VIBRATION_RESET if (device && (!isTuya() || isTuyaTiltXyzAxisSensor )) { input name: 'threeAxis', type: 'enum', title: 'Three Axis', description: 'Enable or disable the Three Axis reporting
(ThirdReality and Samsung)', defaultValue: ThreeAxisOpts.defaultValue, options: ThreeAxisOpts.options } if (device) { input name: 'advancedOptions', type: 'bool', title: 'Advanced Options', description: 'These advanced options should be already automatically set in an optimal way for your device.', defaultValue: false if (advancedOptions == true) { input (name: "shockSensor", type: "bool", title: "Shock Sensor", description: "Simulate a Shock Sensor", defaultValue: true) input name: 'healthCheckMethod', type: 'enum', title: 'Healthcheck Method', options: HealthcheckMethodOpts.options, defaultValue: HealthcheckMethodOpts.defaultValue, required: true, description: 'Method to check device online/offline status.' input name: 'healthCheckInterval', type: 'enum', title: 'Healthcheck Interval', options: HealthcheckIntervalOpts.options, defaultValue: HealthcheckIntervalOpts.defaultValue, required: true, description: 'How often the hub will check the device health.
3 consecutive failures will result in status "offline"
' input "batteryReportingHours", "number", title: "Report battery every $batteryReportingHours hours. Default = 12h (Minimum 2 h)", description: "", range: "2..12", defaultValue: 12 } } } } boolean isTuyaVibrationDoorSensor() { return device.getDataValue("manufacturer") == "_TZE200_kzm5w4iz" // Tuya TS0601 Vibration and Door Sensor } boolean isTuyaVibrationSensorTZ32101000000() { return device?.getDataValue('manufacturer') == '_TZ32101000000_5oy7cysk' } boolean isTuyaTiltXyzAxisSensor() { return TuyaTiltXyzAxisSensorManufacturers.contains(device.getDataValue('manufacturer')) } @Field static final Set TuyaTiltXyzAxisSensorManufacturers = [ '_TZE200_iba1ckek', '_TZE200_hggxgsjj', '_TZE200_yjryxpot', '_TZE200_afycb3cg', '_TZ3210100000_5oy7cysk', '_TZ32101000000_5oy7cysk', ].toSet() boolean supportsTuyaSensitivity() { return isTuya() && isTuyaTiltXyzAxisSensor() } boolean supportsIasSensitivity() { return isTuya() && !supportsTuyaSensitivity() } @Field static final Integer COMMAND_TIMEOUT = 10 // timeout time in seconds @Field static final Integer VIBRATION_RESET = 3 // timeout time in seconds @Field static final Integer MAX_PING_MILISECONDS = 10000 // rtt more than 10 seconds will be ignored @Field static final Integer PRESENCE_COUNT_THRESHOLD = 3 // missing 3 checks will set the device healthStatus to offline @Field static final int PING_ATTR_ID = 0x01 @Field static final Map HealthcheckMethodOpts = [ // used by healthCheckMethod defaultValue: 1, options: [0: 'Disabled', 1: 'Activity check', 2: 'Periodic polling'] ] @Field static final Map HealthcheckIntervalOpts = [ // used by healthCheckInterval defaultValue: 240, options: [10: 'Every 10 Mins', 30: 'Every 30 Mins', 60: 'Every 1 Hour', 240: 'Every 4 Hours', 720: 'Every 12 Hours'] ] @Field static final Map ThreeAxisOpts = [ defaultValue: 1, options: [0: 'Disabled', 1: 'Enabled - Events only', 2: 'Enabled - Events and Logs'] ] @Field static final Map TuyaSensitivityOpts = [ defaultValue: 'middle', options: ['low': 'low', 'middle': 'middle', 'high': 'high'] ] // e8ZoneState is a mandatory attribute which indicates the membership status of the device in an IAS system (enrolled or not enrolled) - one of: @Field static final Map ZONE_STATE = [ 0x00: 'Not Enrolled', 0x01: 'Enrolled' ] // ‘Enrolled’ means that the cluster client will react to Zone State Change Notification commands from the cluster server. // e16ZoneType is a mandatory attribute which indicates the zone type and the types of security detectors that can trigger the alarms, Alarm1 and Alarm2: @Field static final Map ZONE_TYPE = [ 0x0000: 'Standard CIE', 0x000D: 'Motion Sensor', 0x0015: 'Contact Switch', 0x0028: 'Fire Sensor', 0x002A: 'Water Sensor', 0x002B: 'Carbon Monoxide Sensor', 0x002C: 'Personal Emergency Device', 0x002D: 'Vibration Movement Sensor', 0x010F: 'Remote Control', 0x0115: 'Key Fob', 0x021D: 'Key Pad', 0x0225: 'Standard Warning Device', 0x0226: 'Glass Break Sensor', 0x0229: 'Security Repeater', 0xFFFF: 'Invalid Zone Type' ] // b16ZoneStatus is a mandatory attribute which is a 16-bit bitmap indicating the status of each of the possible notification triggers from the device: @Field static final Map ZONE_STATUS = [ 0x0001: 'Alarm 1', // 0 - closed or not alarmed; 1 - opened or alarmed 0x0002: 'Alarm 2', // 0 - closed or not alarmed; 1 - opened or alarmed 0x0004: 'Tamper', // 0 - not tampered with; 1 - tampered with 0x0008: 'Battery', // 0 - battery OK; 1 - Low battery // Bit 4 indicates whether the Zone device issues periodic Zone Status Change Notification commands that may be used by the CIE device as evidence that the Zone device is operational 0x0010: 'Supervision reports', // 0 - does not notify; 1 - notify; // 2 Bit 5 indicates whether the Zone device issues a Zone Status Change Notification command to notify when an alarm is no longer present (some Zone devices do not have the ability to detect when the alarm condition has disappeared). 0x0020: 'Restore reports', // 0 - does not notify on restore; 1 - notify restore 0x0040: 'Trouble', // 0 - OK; 1 - Trouble/Failure 0x0080: 'AC mains', // 0 - AC/Mains OK; 1 - AX/Mains Fault 0x0100: 'Test', // 0 - Sensor is in operation mode; 1 - Sensor is in test mode 0x0200: 'Battery Defect' // 0 - Sensor battery is functioning normally; 1 - Sensor detects a defective battery // bits 10..15 are reserved ] @Field static final Map ENROLL_RESPOSNE_CODE = [ 0x00: 'Success', 0x01: 'Not supported', 0x02: 'No enroll permit', 0x03: 'Too many zones' ] @Field static final Map IAS_ATTRIBUTES = [ // Zone Information 0x0000: 'zone state', 0x0001: 'zone type', 0x0002: 'zone status', // Zone Settings 0x0010: 'CIE addr', // EUI64 0x0011: 'Zone Id', // uint8 0x0012: 'Num zone sensitivity levels supported', // uint8 0x0013: 'Current zone sensitivity level' // uint8 ] @Field static final Map IAS_SERVER_COMMANDS = [ 0x0000: 'enroll response', // uint8 0x0001: 'init normal op mode', // 0x0002: 'init test mode' // uint8, uint8 ] @Field static final Map IAS_CLIENT_COMMANDS = [ 0x0000: 'status change notification', // ZoneStatus, bitmap8, uint8, uint16 0x0001: 'enroll' // ZoneType, uint16 ] // Parse incoming device messages to generate events def parse(String description) { checkDriverVersion(state) updateRxStats(state) unscheduleCommandTimeoutCheck(state) setHealthStatusOnline(state) Map map = [:] logDebug("Parsing: $description") Map event = [:] try { event = zigbee.getEvent(description) } catch ( e ) { if (logEnable) log.warn "exception caught while decoding event description: ${description}" // return null // ignore and continue, changed 05/19/2024 } // if (event) { if (event.name == 'battery') { event.unit = '%' event.isStateChange = true event.descriptionText = "battery is ${event.value} ${event.unit}" sendLastBatteryEvent() } else if (event.name == "batteryVoltage") { event.unit = "V" event.isStateChange = true event.descriptionText = "battery voltage is ${event.value} volts" sendLastBatteryEvent() } else { logDebug("event: $event") } logInfo(event.descriptionText) return createEvent(event) } else if (description?.startsWith('enroll request')) { //------IAS Zone Enroll request------// logDebug "Scheduling IAS enroll response after 1 second..." runIn(1, "sendEnrollResponse") } else 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 = parseBatteryVoltage(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: if (txtEnable) { log.warn("Unknown reporting configured: ${descMap}") } break } } else { if (logEnable) { 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.clusterInt == 0x0500 && descMap.attrInt == 0x0013) { String descText = "IAS Zone Sensitivity: ${descMap.value}" int iSens = descMap.value?.toInteger() logInfo "vibration sensitivity : ${iSens}" sendEvent(name: "sensitivity", value: iSens, descText: descText) if (iSens>=0 && iSens<7) { device.updateSetting("sensitivity",[value:iSens.toString(), type:"enum"]) } else { logDebug "unsupported sensitivity value ${iSens} !" } } else if (descMap.profileId == "0000") { logDebug "ignored ZDO messages " } else if (descMap.clusterInt == zigbee.BASIC_CLUSTER && descMap.attrInt == PING_ATTR_ID) { handlePingResponse(descMap) } else if (descMap.clusterInt == 0xFFF1 && descMap.command in ['01', '0A']) { handleThreeAxisTR(descMap) } else if (descMap.clusterInt == 0xFC02 && descMap.command in ['01', '0A']) { handleThreeAxisSamsung(descMap) } else if ((descMap?.clusterInt == 0xEF00) && (descMap?.command == '01' || descMap?.command == '02' || descMap?.command == '06')) { int dataLen = descMap?.data.size() //log.warn "dataLen=${dataLen}" //def transid = zigbee.convertHexToInt(descMap?.data[1]) // "transid" is just a "counter", a response will have the same transid as the command if (dataLen <= 5) { logWarn "unprocessed short Tuya command response: dp_id=${descMap?.data[3]} dp=${descMap?.data[2]} fncmd_len=${fncmd_len} data=${descMap?.data})" return } boolean isSpammyDeviceProfileDefined = this.respondsTo('isSpammyDeviceProfile') // check if the method exists 05/21/2024 for (int i = 0; i < (dataLen - 4); ) { int dp = zigbee.convertHexToInt(descMap?.data[2 + i]) // "dp" field describes the action/message of a command frame int dp_id = zigbee.convertHexToInt(descMap?.data[3 + i]) // "dp_identifier" is device dependant int fncmd_len = zigbee.convertHexToInt(descMap?.data[5 + i]) int fncmd = getTuyaAttributeValue(descMap?.data, i) // processTuyaDP(descMap, dp, dp_id, fncmd) i = i + fncmd_len + 4 } } // if (descMap?.command == "01" || descMap?.command == "02") else { if (logEnable) log.warn ("Description map not parsed: $descMap") } } else { if (logEnable) log.warn "Description not parsed: $description" } if (map != null && map != [:]) { logInfo(map?.descriptionText) return createEvent(map) } else { return [:] } } private int getTuyaAttributeValue(final List _data, final int index) { int retValue = 0 if (_data.size() >= 6) { int dataLength = zigbee.convertHexToInt(_data[5 + index]) if (dataLength == 0) { return 0 } int power = 1 for (i in dataLength..1) { retValue = retValue + power * zigbee.convertHexToInt(_data[index + i + 5]) power = power * 256 } } return retValue } void processTuyaDP(final Map descMap, final int dp, final int dp_id, final int fncmd) { switch (dp) { case 0x01: if (isTuyaVibrationSensorTZ32101000000()) { // per Z2M converter this model reports vibration on DP 0x68 (104) logDebug "Tuya cmd (TZ32101000000): ignoring dp=${dp} value=${fncmd} descMap.data = ${descMap?.data}" } else if (isTuyaVibrationDoorSensor()) { logDebug "Tuya cmd: dp=${dp} value=${fncmd} descMap.data = ${descMap?.data}" logInfo "TuyaVibrationDoorSensor: contact is ${fncmd == 1 ? 'open' : 'closed'}" // TODO - create a child device? } else { // isTuyaTiltXyzAxisSensor() - Vibration State logDebug "Tuya Vibration State cmd: dp=${dp} value=${fncmd} descMap.data = ${descMap?.data}" sendVibrationEvent(fncmd != 0) } break case 0x02: // ? logDebug "Tuya cmd: dp=${dp} value=${fncmd} descMap.data = ${descMap?.data}" break case 0x03: // thitBatteryPercentage isTuyaVibrationDoorSensor() TS0601 _TZE200_kzm5w4iz logDebug "Tuya cmd: dp=${dp} value=${fncmd} descMap.data = ${descMap?.data}" sendBatteryPercentageEvent(fncmd) sendLastBatteryEvent() break case 0x04: // (4) Battery percentage (TZ32101000000 per Z2M) if (isTuyaVibrationSensorTZ32101000000()) { logDebug "Tuya battery cmd (TZ32101000000): dp=${dp} value=${fncmd} descMap.data = ${descMap?.data}" sendBatteryPercentageEvent(fncmd) sendLastBatteryEvent() } else { logDebug "NOT PROCESSED Tuya cmd: dp=${dp} value=${fncmd} descMap.data = ${descMap?.data}" } break case 0x07 :// tilt logDebug "Tuya tilt cmd: dp=${dp} value=${fncmd} descMap.data = ${descMap?.data}" sendTiltEvent(fncmd != 0) break case 0x0A: // (10) tuyaVibration isTuyaVibrationDoorSensor() TS0601 _TZE200_kzm5w4iz logDebug "Tuya cmd: dp=${dp} value=${fncmd} descMap.data = ${descMap?.data}" sendVibrationEvent(fncmd != 0) break case 0x65: // (101) X-axis acceleration logDebug "Tuya X-axis acceleration cmd: dp=${dp} value=${fncmd} descMap.data = ${descMap?.data}" state.lastAcceleration['x'] = fncmd break case 0x66: // (102) Y-axis acceleration logDebug "Tuya 102) Y-axis acceleration cmd: dp=${dp} value=${fncmd} descMap.data = ${descMap?.data}" state.lastAcceleration['y'] = fncmd break case 0x67: // (103) Z-axis acceleration logDebug "Tuya Z-axis acceleration cmd: dp=${dp} value=${fncmd} descMap.data = ${descMap?.data}" state.lastAcceleration['z'] = fncmd sendTuyaThreeAxisEvent(state.lastAcceleration.x, state.lastAcceleration.y, state.lastAcceleration.z) break case 0x68: // (104) Sensitivity Setting if (isTuyaVibrationSensorTZ32101000000()) { // per Z2M converter this model reports vibration on DP 0x68 (104) logDebug "Tuya vibration cmd (TZ32101000000): dp=${dp} value=${fncmd} descMap.data = ${descMap?.data}" sendVibrationEvent(fncmd != 0) } else { logDebug "Tuya Sensitivity Setting cmd: dp=${dp} value=${fncmd} descMap.data = ${descMap?.data}" if (supportsTuyaSensitivity()) { String sens switch (fncmd) { case 0: sens = 'low' ; break case 1: sens = 'middle' ; break case 2: sens = 'high' ; break default: logWarn "unsupported Tuya sensitivity value ${fncmd}" break } if (sens != null) { sendEvent(name: 'tuyaSensitivity', value: sens, descriptionText: "Tuya sensitivity is ${sens}") if (settings?.tuyaSensitivity != sens) { device.updateSetting('tuyaSensitivity', [value: sens, type: 'enum']) } } } } break case 0x69: // (105) Battery Percentage if (isTuyaVibrationSensorTZ32101000000()) { // per Z2M converter this model reports sensitivity on DP 0x69 (105) logDebug "Tuya sensitivity cmd (TZ32101000000): dp=${dp} value=${fncmd} descMap.data = ${descMap?.data}" if (supportsTuyaSensitivity()) { String sens switch (fncmd) { case 0: sens = 'low' ; break case 1: sens = 'middle' ; break case 2: sens = 'high' ; break default: logWarn "unsupported Tuya sensitivity value ${fncmd}" break } if (sens != null) { sendEvent(name: 'tuyaSensitivity', value: sens, descriptionText: "Tuya sensitivity is ${sens}") if (settings?.tuyaSensitivity != sens) { device.updateSetting('tuyaSensitivity', [value: sens, type: 'enum']) } } } } else { logDebug "Tuya cmd: dp=${dp} value=${fncmd} descMap.data = ${descMap?.data}" sendBatteryPercentageEvent(fncmd) sendLastBatteryEvent() } break default : logDebug "NOT PROCESSED Tuya cmd: dp=${dp} value=${fncmd} descMap.data = ${descMap?.data}" break } } void sendTuyaThreeAxisEvent(Integer x, Integer y, Integer z) { convertXYZtoPsiPhiTheta(x ?: 0, y ?: 0, z ?: 0) } void sendTiltEvent(boolean tiltActive) { logDebug "Tilt : $tiltActive" Map result = handleTilt(tiltActive) if (result != [:]) { sendEvent(result) logInfo (result.descriptionText) } else { logDebug "Tilt event not sent" } } private handleTilt(boolean tiltActive) { if (tiltActive) { if (device.currentState('tilt')?.value != "detected") { // Tilt detected return getTiltResult(true) } else { logDebug "Tilt already detected" return [:] } } else { if (device.currentState('tilt')?.value == "detected") { // Tilt reset to inactive return getTiltResult(false) } else { logDebug "Tilt was already cleared" return [:] } } } Map getTiltResult(tiltActive) { String descriptionText = "Tilt detected" if (!tiltActive) { descriptionText = "Tilt reset to clear" } return [ name : 'tilt', value : tiltActive ? 'detected' : 'clear', type : 'physical', descriptionText : descriptionText ] } def sendBatteryPercentageEvent(rawValuePar) { def rawValue = rawValuePar as int logDebug "sendBatteryPercentageEvent: rawValue = ${rawValue}" def result = [:] if (rawValue < 0) { rawValue = 0; logWarn "batteryPercentage rawValue corrected to ${rawValue}" } if (rawValue > 100 ) { rawValue = 100; logWarn "batteryPercentage rawValue corrected to ${rawValue}" } result.name = 'battery' result.translatable = true result.value = Math.round(rawValue) result.descriptionText = "${device.displayName} battery is ${result.value}%" result.isStateChange = true result.unit = '%' result.type = 'physical' sendEvent(result) } def sendEnrollResponse() { logDebug "Sending a scheduled IAS enroll response..." List cmds = zigbee.enrollResponse() + zigbee.readAttribute(0x0500, 0x0000) sendZigbeeCommands(cmds) } // helpers ------------------- Map parseIasMessage(ZoneStatus zs) { String currentAccel = device.currentState('acceleration')?.value String zsStr = '' zs.properties.sort().each { key, value -> zsStr += "$key = $value, "} if (logEnable) log.debug "current acceleration = ${currentAccel} new Zone status message zs = ${zsStr}" // check for vibration active if (zs.alarm1Set == true || zs.alarm2Set == true) { if (currentAccel != "active") { // Vibration detected return handleVibration(true) } else { logDebug "Vibration already active" return [:] } } else if (zs.alarm1Set == false && zs.alarm2Set == false) { if (currentAccel == "active") { // Vibration reset to inactive return handleVibration(false) } else { logDebug "Vibration already inactive" return [:] } } else { logWarn "Unsupported IAS Zone status: ${zsStr}" return [:] } } // called when processing Tuya TS0601 model EF00 cluster commands void sendVibrationEvent(boolean vibrationActive) { log.trace "Vibration : $vibrationActive" Map result = handleVibration(vibrationActive) if (result != [:]) { sendEvent(result) logInfo (result.descriptionText) if (settings.shockSensor == true) { sendEvent(getShockResult(vibrationActive)) } } else { logDebug "Vibration event not sent" } } Map handleVibration(boolean vibrationActive) { if (vibrationActive) { int timeout = vibrationReset ?: 3 // Some sensors will send only a vibration detected message, so reset to vibration inactive is performed in code runIn(timeout, resetToVibrationInactive, [overwrite: true]) if (device.currentState('acceleration')?.value != "active") { state.vibrationStarted = now() } sendEvent(getShockResult(vibrationActive)) return getVibrationResult(vibrationActive) } else { // vibration inactive event unschedule('resetToVibrationInactive') if (device.currentState('acceleration')?.value != "inactive") { sendEvent(getShockResult(vibrationActive)) return getVibrationResult(vibrationActive) } else { logDebug "Vibration already inactive" return [:] } } } Map getVibrationResult(vibrationActive) { String descriptionText = "Vibration detected" if (!vibrationActive) { descriptionText = "Vibration reset to inactive after ${getSecondsInactive()}s" } return [ name : 'acceleration', value : vibrationActive ? 'active' : 'inactive', type : 'physical', descriptionText : descriptionText ] } Map getShockResult(shockActive) { String descriptionText = "Shock detected" if (!shockActive) { descriptionText = "Shock reset to inactive" } return [ name : 'shock', value : shockActive ? 'detected' : 'clear', type : 'physical', descriptionText : descriptionText ] } void setAccelarationInactive() { resetToVibrationInactive(true) } void resetToVibrationInactive(boolean isDigital = false) { if (device.currentState('acceleration')?.value == "active") { String type = isDigital ? "digital" : "physical" String descText = "Vibration reset to inactive after ${getSecondsInactive()}s [$type]" sendEvent( name : "acceleration", value : "inactive", isStateChange : true, type : type, descriptionText : descText ) logInfo(descText) if (settings.shockSensor == true) { sendEvent(getShockResult(false)) } } } int getSecondsInactive() { if (state.vibrationStarted) { return Math.round((now() - state.vibrationStarted)/1000) } else { return vibrationReset ?: 3 } } // 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 parseBatteryVoltage(valueHex) { //logDebug("Battery parse string = ${valueHex}") def rawVolts = Integer.parseInt(valueHex, 16) / 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 ] sendLastBatteryEvent() return result } void sendLastBatteryEvent() { final Date lastBattery = new Date() sendEvent(name: 'lastBattery', value: lastBattery, descriptionText: "Last battery event at ${lastBattery}") } String getDEGREE() { return String.valueOf((char)(176)) } import groovy.json.JsonOutput /* Some parts borrowed from veeceeoh in this method */ void convertXYZtoPsiPhiTheta(int x, int y, int z) { BigDecimal psi = new BigDecimal(Math.atan(x.div(Math.sqrt(z * z + y * y))) * 180 / Math.PI).setScale(1, BigDecimal.ROUND_HALF_UP) BigDecimal phi = new BigDecimal(Math.atan(y.div(Math.sqrt(x * x + z * z))) * 180 / Math.PI).setScale(1, BigDecimal.ROUND_HALF_UP) BigDecimal theta = new BigDecimal(Math.atan(z.div(Math.sqrt(x * x + y * y))) * 180 / Math.PI).setScale(1, BigDecimal.ROUND_HALF_UP) logDebug "Calculated angles are Psi = ${psi}$DEGREE, Phi = ${phi}$DEGREE, Theta = ${theta}$DEGREE Raw accelerometer XYZ axis values = $x, $y, $z" String json = JsonOutput.toJson([x:x, y:y, z:z, psi:psi, phi:phi, theta:theta]) if ((settings.threeAxis as int) == 2) { // 2 - Enabled - Events and Logs log.info "threeAxis : ${json}" } if ((settings.threeAxis as int) > 0) { // 1 - Enabled - Events only sendEvent(name: 'threeAxis', value: json, isStateChange: true) } } void handleThreeAxisTR(final Map descMap) { logDebug "handleThreeAxisTR: descMap = ${descMap}" boolean isValid = descMap.value == "0001" int x, y, z descMap.additionalAttrs.each { attr -> int axis = zigbee.convertHexToInt(attr.value) if (axis > 0x7FFF) { axis = axis - 0x10000 } if (attr.attrInt == 1) { x = axis } else if (attr.attrInt == 2) { y = axis } else if (attr.attrInt == 3) { z = axis } } if (isValid) { convertXYZtoPsiPhiTheta(x, y, z) } } void handleThreeAxisSamsung(final Map descMap) { logDebug "handleThreeAxisSamsung: descMap = ${descMap}" if (descMap.attrInt == 0x0010) { // read attr - raw: DC8401FC020810001801, dni: DC84, endpoint: 01, cluster: FC02, size: 08, attrId: 0010, encoding: 18, command: 0A, value: 01 Map event = handleVibration(descMap.value == "01") if (event) { sendEvent(event) logInfo event.descriptionText } return } else if (descMap.attrInt == 0x0012) { int x, y, z x = zigbee.convertHexToInt(descMap.value) if (x > 0x7FFF) { x = x - 0x10000 } descMap.additionalAttrs.each { attr -> int axis = zigbee.convertHexToInt(attr.value) if (axis > 0x7FFF) { axis = axis - 0x10000 } if (attr.attrInt == 19) { y = axis } else if (attr.attrInt == 20) { z = axis } } if ((x != null) && (y != null) && (z != null)) { convertXYZtoPsiPhiTheta(x, y, z) } } else { logWarn "handleThreeAxisSamsung: unsupported attrInt=${descMap.attrInt}" } return } // lifecycle methods ------------- // installed() runs just after a sensor is paired def installed() { logInfo "Installing..." sendEvent(name: 'healthStatus', value: 'unknown') initializeVars(fullInit = true) updateTuyaVersion() refresh() } // configure() runs after installed() when a sensor is paired or reconnected void configure() { logInfo("Configuring") configureReporting() } List queryAllTuyaDP() { logDebug 'queryAllTuyaDP()' List cmds = zigbee.command(0xEF00, 0x03) return cmds } void refresh() { logInfo("Refreshing...") List cmds = [] cmds += zigbee.readAttribute(zigbee.POWER_CONFIGURATION_CLUSTER, 0x0020, [:], delay=200) // battery voltage if (supportsIasSensitivity()) { cmds += zigbee.readAttribute(0x0500, 0x0013, [:], delay=200) // IAS sensitivity } if (device?.getDataValue('manufacturer') == 'Samjin') { cmds += zigbee.readAttribute(0xFC02, [0x0010, 0x0012], [:], delay=200) // vibration and three axis } else if (device?.getDataValue('manufacturer') == 'Third Reality, Inc') { cmds += zigbee.readAttribute(0xFFF1, [0x0000, 0x0001, 0x0002, 0x0003], [:], delay=200) // vibration and three axis } if (isTuya()) { cmds += queryAllTuyaDP() } sendZigbeeCommands(cmds) } // updated() runs every time user saves preferences void updated() { checkDriverVersion(state) logInfo("Updating settings...") // added 2026-02-01 unschedule() // added 05/21/2024 if (logEnable == true) { runIn(86400, 'logsOff', [overwrite: true, misfire: 'ignore']) // turn off debug logging after 30 minutes if (settings?.txtEnable) { log.info "${device.displayName} Debug logging will be turned off after 24 hours" } } else { unschedule('logsOff') } final int healthMethod = (settings.healthCheckMethod as Integer) ?: 0 if (healthMethod == 1 || healthMethod == 2) { // [0: 'Disabled', 1: 'Activity check', 2: 'Periodic polling'] // schedule the periodic timer final int interval = (settings.healthCheckInterval as Integer) ?: 0 if (interval > 0) { //log.trace "healthMethod=${healthMethod} interval=${interval}" log.info "scheduling health check every ${interval} minutes by ${HealthcheckMethodOpts.options[healthCheckMethod as int]} method" scheduleDeviceHealthCheck(interval, healthMethod) } } else { unScheduleDeviceHealthCheck() // unschedule the periodic job, depending on the healthMethod logInfo 'Health Check is disabled!' } if (settings.shockSensor == true) { logInfo "Shock Sensor is enabled" if (device.currentState('shock') == null) { sendEvent(getShockResult(false)) } } else { logInfo "Shock Sensor is disabled" if (device.currentState('shock') != null) { device.deleteCurrentState('shock') } } String currentTreeAxis = device.currentState('threeAxis')?.value logInfo("Updating preference settings, sensitivity = ${settings.sensitivity}, tuyaSensitivity = ${settings.tuyaSensitivity}, threeAxisOpt = ${settings.threeAxis}, currentTreeAxis = $currentTreeAxis}") if (settings.threeAxis as int == 0 && currentTreeAxis != null) { logInfo "Three Axis reporting is now disabled" device.deleteCurrentState('threeAxis') } else if (settings.threeAxis as int != 0 && currentTreeAxis == null) { logInfo "Three Axis reporting is now enabled with option ${settings.threeAxis}" } configureReporting() } private int getTuyaSensitivityDp() { // Default Tuya XYZ family uses DP 0x68 for sensitivity; _TZ32101000000_5oy7cysk uses DP 0x69 per Z2M converter return isTuyaVibrationSensorTZ32101000000() ? 0x69 : 0x68 } private List tuyaSetEnumDp(final int dp, final int value, final int transId = null) { int tid = transId if (tid == null) { int prev = (state?.tuyaTransId ?: 0) as int tid = (prev + 1) & 0xFF state.tuyaTransId = tid } // Tuya EF00 payload format (common): status(00) + transId + dpId + dpType(enum=04) + lenHi(00) + lenLo(01) + value String payload = "00" + zigbee.convertToHexString(tid, 2) + zigbee.convertToHexString(dp, 2) + "04" + "00" + "01" + zigbee.convertToHexString(value & 0xFF, 2) return zigbee.command(0xEF00, 0x00, payload) } private List setTuyaSensitivity(final String sens) { if (!supportsTuyaSensitivity()) { return [] } Integer enumVal = null switch (sens) { case 'low': enumVal = 0 ; break case 'middle': enumVal = 1 ; break case 'high': enumVal = 2 ; break default: logWarn "setTuyaSensitivity: unsupported value ${sens}" return [] } int dp = getTuyaSensitivityDp() logDebug "Sending Tuya sensitivity set command dp=${dp} value=${sens} (${enumVal})" return tuyaSetEnumDp(dp, enumVal) } boolean isTuya() { if (!device) { return true } String model = device.getDataValue('model') String manufacturer = device.getDataValue('manufacturer') /* groovylint-disable-next-line UnnecessaryTernaryExpression */ return (model?.startsWith('T') && manufacturer?.startsWith('_T')) ? true : false } void updateTuyaVersion() { if (!isTuya()) { logDebug 'not Tuya' ; return } final String application = device.getDataValue('application') if (application != null) { Integer ver try { ver = zigbee.convertHexToInt(application) } catch (e) { logWarn "exception caught while converting application version ${application} to tuyaVersion" return } final String str = ((ver & 0xC0) >> 6).toString() + '.' + ((ver & 0x30) >> 4).toString() + '.' + (ver & 0x0F).toString() if (device.getDataValue('tuyaVersion') != str) { device.updateDataValue('tuyaVersion', str) logInfo "tuyaVersion set to $str" } } } public void ping() { if (state.lastTx == null ) { state.lastTx = [:] } ; state.lastTx['pingTime'] = new Date().getTime() if (state.states == null ) { state.states = [:] } ; state.states['isPing'] = true scheduleCommandTimeoutCheck() sendZigbeeCommands(zigbee.readAttribute(zigbee.BASIC_CLUSTER, 0x01, [:], 0) ) logDebug 'ping...' } void handlePingResponse(final Map descMap) { boolean isPing = state.states['isPing'] ?: false Long now = new Date().getTime() if (state.lastRx == null) { state.lastRx = [:] } state.lastRx['checkInTime'] = now if (isPing) { int timeRunning = now.toInteger() - (state.lastTx['pingTime'] ?: '0').toInteger() if (timeRunning > 0 && timeRunning < MAX_PING_MILISECONDS) { state.stats['pingsOK'] = (state.stats['pingsOK'] ?: 0) + 1 if (timeRunning < (state.stats['pingsMin'] ?: 999)) { state.stats['pingsMin'] = timeRunning } if (timeRunning > (state.stats['pingsMax'] ?: 0)) { state.stats['pingsMax'] = timeRunning } state.stats['pingsAvg'] = approxRollingAverage(state.stats['pingsAvg'], timeRunning) as int sendRttEvent() } else { logWarn "unexpected ping timeRunning=${timeRunning} " } state.states['isPing'] = false } else { logDebug "Tuya check-in message (attribute ${descMap.attrId} reported: ${descMap.value})" } } @Field static final int ROLLING_AVERAGE_N = 10 BigDecimal approxRollingAverage(BigDecimal avgPar, BigDecimal newSample) { BigDecimal avg = avgPar if (avg == null || avg == 0) { avg = newSample } avg -= avg / ROLLING_AVERAGE_N avg += newSample / ROLLING_AVERAGE_N return avg } // helpers ------------- static void updateRxStats(final Map state) { if (state.stats != null) { state.stats['rxCtr'] = (state.stats['rxCtr'] ?: 0) + 1 } else { state.stats = [:] } } private void scheduleCommandTimeoutCheck(int delay = COMMAND_TIMEOUT) { if (state.states == null) { state.states = [:] } state.states['isTimeoutCheck'] = true runIn(delay, 'deviceCommandTimeout') } void unscheduleCommandTimeoutCheck(final Map state) { // can not be static :( if (state.states == null) { state.states = [:] } if (state.states['isTimeoutCheck'] == true) { state.states['isTimeoutCheck'] = false unschedule('deviceCommandTimeout') } } void deviceCommandTimeout() { logWarn 'no response received (sleepy device or offline?)' sendRttEvent('timeout') state.stats['pingsFail'] = (state.stats['pingsFail'] ?: 0) + 1 } void sendRttEvent( String value=null) { Long now = new Date().getTime() if (state.lastTx == null ) { state.lastTx = [:] } int timeRunning = now.toInteger() - (state.lastTx['pingTime'] ?: now).toInteger() String descriptionText = "Round-trip time is ${timeRunning} ms (min=${state.stats['pingsMin']} max=${state.stats['pingsMax']} average=${state.stats['pingsAvg']})" if (value == null) { logInfo "${descriptionText}" sendEvent(name: 'rtt', value: timeRunning, descriptionText: descriptionText, unit: 'ms', isDigital: true) } else { descriptionText = "Round-trip time : ${value}" logInfo "${descriptionText}" sendEvent(name: 'rtt', value: value, descriptionText: descriptionText, isDigital: true) } } String getCron(int timeInSeconds) { //schedule("${rnd.nextInt(59)} ${rnd.nextInt(9)}/${intervalMins} * ? * * *", 'ping') // TODO: runEvery1Minute runEvery5Minutes runEvery10Minutes runEvery15Minutes runEvery30Minutes runEvery1Hour runEvery3Hours final Random rnd = new Random() int minutes = (timeInSeconds / 60 ) as int int hours = (minutes / 60 ) as int if (hours > 23) { hours = 23 } String cron if (timeInSeconds < 60) { cron = "*/$timeInSeconds * * * * ? *" } else { if (minutes < 60) { cron = "${rnd.nextInt(59)} ${rnd.nextInt(9)}/$minutes * ? * *" } else { cron = "${rnd.nextInt(59)} ${rnd.nextInt(59)} */$hours ? * *" } } return cron } /** * Schedule a device health check * @param intervalMins interval in minutes */ private void scheduleDeviceHealthCheck(final int intervalMins, final int healthMethod) { if (healthMethod == 1 || healthMethod == 2) { String cron = getCron( intervalMins * 60 ) schedule(cron, 'deviceHealthCheck') logDebug "deviceHealthCheck is scheduled every ${intervalMins} minutes" } else { logWarn 'deviceHealthCheck is not scheduled!' unschedule('deviceHealthCheck') } } private void unScheduleDeviceHealthCheck() { unschedule('deviceHealthCheck') device.deleteCurrentState('healthStatus') logWarn 'device health check is disabled!' } // called when any event was received from the Zigbee device in the parse() method. void setHealthStatusOnline(Map state) { if (state.health == null) { state.health = [:] } state.health['checkCtr3'] = 0 if (!((device.currentValue('healthStatus') ?: 'unknown') in ['online'])) { sendHealthStatusEvent('online') logInfo 'is now online!' } } void deviceHealthCheck() { checkDriverVersion(state) if (state.health == null) { state.health = [:] } int ctr = state.health['checkCtr3'] ?: 0 if (ctr >= PRESENCE_COUNT_THRESHOLD) { if ((device.currentValue('healthStatus') ?: 'unknown') != 'offline' ) { logWarn 'not present!' sendHealthStatusEvent('offline') } } else { logDebug "deviceHealthCheck - online (notPresentCounter=${ctr})" } state.health['checkCtr3'] = ctr + 1 } void sendHealthStatusEvent(final String value) { String descriptionText = "healthStatus changed to ${value}" sendEvent(name: 'healthStatus', value: value, descriptionText: descriptionText, isStateChange: true, isDigital: true) if (value == 'online') { logInfo "${descriptionText}" } else { if (settings?.txtEnable) { log.warn "${device.displayName}} ${descriptionText}" } } } String driverVersionAndTimeStamp() { version() + ' ' + timeStamp() + ((_DEBUG) ? ' (debug version!) ' : ' ') + "(${device.getDataValue('model')} ${device.getDataValue('manufacturer')}) (${getModel()} ${location.hub.firmwareVersionString})" } String getDeviceInfo() { return "model=${device.getDataValue('model')} manufacturer=${device.getDataValue('manufacturer')} destinationEP=${state.destinationEP ?: UNKNOWN} deviceProfile=${state.deviceProfile ?: UNKNOWN}" } // credits @thebearmay String getModel() { try { /* groovylint-disable-next-line UnnecessaryGetter, UnusedVariable */ String model = getHubVersion() // requires >=2.2.8.141 } catch (ignore) { try { httpGet("http://${location.hub.localIP}:8080/api/hubitat.xml") { res -> model = res.data.device.modelName return model } } catch (ignore_again) { return '' } } } @CompileStatic void checkDriverVersion(final Map state) { if (state.driverVersion == null || driverVersionAndTimeStamp() != state.driverVersion) { logDebug "checkDriverVersion: updating the settings from the current driver version ${state.driverVersion} to the new version ${driverVersionAndTimeStamp()}" state.driverVersion = driverVersionAndTimeStamp() initializeVars(false) updateTuyaVersion() } if (state.states == null) { state.states = [:] } if (state.lastRx == null) { state.lastRx = [:] } if (state.lastTx == null) { state.lastTx = [:] } if (state.stats == null) { state.stats = [:] } if (state.lastAcceleration == null) { state.lastAcceleration = [:] } } void resetStats() { logDebug 'resetStats...' state.stats = [:] ; state.states = [:] ; state.lastRx = [:] ; state.lastTx = [:] ; state.health = [:] state.stats['rxCtr'] = 0 ; state.stats['txCtr'] = 0 state.states['isDigital'] = false ; state.states['isRefresh'] = false ; state.states['isPing'] = false state.health['offlineCtr'] = 0 ; state.health['checkCtr3'] = 0 } void initializeVars( boolean fullInit = false ) { logDebug "InitializeVars()... fullInit = ${fullInit}" if (fullInit == true ) { state.clear() unschedule() resetStats() state.comment = 'Works with Tuya TS0210 and TR Vibration Sensors' logInfo 'all states and scheduled jobs cleared!' state.driverVersion = driverVersionAndTimeStamp() } if (state.stats == null) { state.stats = [:] } if (state.states == null) { state.states = [:] } if (state.lastRx == null) { state.lastRx = [:] } if (state.lastTx == null) { state.lastTx = [:] } if (state.health == null) { state.health = [:] } if (fullInit || settings?.txtEnable == null) { device.updateSetting('txtEnable', true) } if (fullInit || settings?.logEnable == null) { device.updateSetting('logEnable', DEFAULT_DEBUG_LOGGING ?: false) } if (fullInit || settings?.advancedOptions == null) { device.updateSetting('advancedOptions', [value:false, type:'bool']) } if (fullInit || settings?.healthCheckMethod == null) { device.updateSetting('healthCheckMethod', [value: HealthcheckMethodOpts.defaultValue.toString(), type: 'enum']) } if (fullInit || settings?.healthCheckInterval == null) { device.updateSetting('healthCheckInterval', [value: HealthcheckIntervalOpts.defaultValue.toString(), type: 'enum']) } if (device.currentValue('healthStatus') == null) { sendHealthStatusEvent('unknown') } if (fullInit || settings?.voltageToPercent == null) { device.updateSetting('voltageToPercent', false) } if (fullInit || settings?.threeAxis == null) { device.updateSetting('threeAxis', [value: ThreeAxisOpts.defaultValue.toString(), type: 'enum']) } if (fullInit || settings?.shockSensor == null) { device.updateSetting('shockSensor', true) } final String ep = device.getEndpointId() if ( ep != null) { logDebug " destinationEP = ${ep}" } else { logWarn ' Destination End Point not found, please re-pair the device!' } } void logsOff() { log.warn "${device.displayName} debug logging disabled..." device.updateSetting('logEnable', [value: 'false', type: 'bool']) } void configureReporting() { int seconds = Math.round((settings?.batteryReportingHours ?: 12)*3600) logInfo("Battery reporting frequency: ${seconds/3600}h") List cmds = [] cmds += zigbee.readAttribute(0x0000, [0x0004, 0x000, 0x0001, 0x0005, 0x0007, 0xfffe], [:], delay=200) cmds += zigbee.configureReporting(zigbee.POWER_CONFIGURATION_CLUSTER, 0x0020, DataType.UINT8, seconds-1, seconds, 0x00, [:], delay=200) cmds += zigbee.readAttribute(zigbee.POWER_CONFIGURATION_CLUSTER, 0x20, [:], delay=200) // added 03/07/2023 cmds += zigbee.enrollResponse(200) + zigbee.readAttribute(0x0500, 0x0000, [:], delay=200) // if (settings?.sensitivity != null && supportsIasSensitivity()) { logDebug("Configuring IAS vibration sensitivity to : ${settings?.sensitivity}") int iSens = settings.sensitivity?.toInteger() if (iSens>=0 && iSens<7) { cmds += zigbee.writeAttribute(0x0500, 0x0013, DataType.UINT8, iSens, [:], delay=200) } } if (supportsTuyaSensitivity() && settings?.tuyaSensitivity != null) { cmds += setTuyaSensitivity(settings.tuyaSensitivity as String) } sendZigbeeCommands(cmds) } void sendZigbeeCommands(List cmd) { if (cmd == null || cmd.isEmpty()) { logWarn "sendZigbeeCommands: list is empty! cmd=${cmd}" return } hubitat.device.HubMultiAction allActions = new hubitat.device.HubMultiAction() cmd.each { if (it == null || it.isEmpty() || it == 'null') { logWarn "sendZigbeeCommands it: no commands to send! it=${it} (cmd=${cmd})" return } allActions.add(new hubitat.device.HubAction(it, hubitat.device.Protocol.ZIGBEE)) if (state.stats != null) { state.stats['txCtr'] = (state.stats['txCtr'] ?: 0) + 1 } else { state.stats = [:] } } if (state.lastTx != null) { state.lastTx['cmdTime'] = now() } else { state.lastTx = [:] } sendHubCommand(allActions) logDebug "sendZigbeeCommands: sent cmd=${cmd}" } private def logDebug(message) { if (logEnable) { log.debug "${device.displayName}: ${message}" } } private def logInfo(message) { if (txtEnable) { log.info "${device.displayName}: ${message}" } } private def logWarn(message) { if (logEnable) { log.warn "${device.displayName}: ${message}" } }