/* groovylint-disable CompileStatic, DuplicateMapLiteral, DuplicateNumberLiteral, DuplicateStringLiteral, ImplementationAsType, MethodCount, MethodParameterTypeRequired, MethodSize, NestedBlockDepth, NoDef, NoWildcardImports, ParameterName, UnnecessaryObjectReferences, UnusedPrivateMethod, VariableTypeRequired */
/**
* Tuya Zigbee Smoke Detector driver for Hubitat Elevation
*
* https://community.hubitat.com/t/beta-tuya-zigbee-smoke-detector/104159
*
* 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.0 2022-10-29 kkossev - inital version for _TZE200_ntcy3xu1
* ver. 1.0.1 2022-10-31 kkossev - added _TZE200_uebojraa
* ver. 1.0.2 2022-11-17 kkossev - notPresentCounter set to 12 hours; states set to 'unknown' on device creation; added Clear Detected Tested buttons; removed Configure button
* ver. 1.0.3 2022-12-15 kkossev - added _TZE200_e2bedvo9
* ver. 1.1.0 2023-04-07 kkossev - extended tuyaMagic (hopefully activates check-in every 4 hours); added capability 'Health Check'; added ping() command and rtt measurement;
* ver. 1.1.1 2023-04-29 kkossev - ping() exception bug fix
* ver. 1.1.2 2023-08-01 kkossev - added _TZE200_m9skfctm _TZE200_dq1mfjug _TZE200_ux5v4dbd _TZE200_ytibqbra _TZE200_dnz6yvl2
* ver. 1.1.3 2023-11-19 kkossev - (dev. branch) fixed _TZE200_m9skfctm battery reporting; fix RTT negative values bug
* ver. 1.1.3 2024-03-26 hubivlad -(main branch) added _TZE200_rccxox8p
* ver. 1.2.0 2024-02-20 kkossev - (dev. branch) Groovy lint; added TZE204_ntcy3xu1
* ver. 1.2.1 2024-03-27 kkossev - merged main branch ver. 1.1.3 commit by hubivlad
*
* TODO: re-send the powerSource event on every check-in, so that HE Active state is refreshed ...
* TODO: more tuyaMagic, if the periodic check-in patch doesn't work.
* TODO: send the check-in messages as an event / show as Info log
* TODO: add 'Silence' / Clear command for _TZE200_ntcy3xu1
* TODO: add [digital] in the logs
*/
import groovy.json.*
import groovy.transform.Field
def version() { '1.2.1' }
def timeStamp() { '2024/03/27 7:24 AM' }
@Field static final Boolean _DEBUG = false
metadata {
definition(name: 'Tuya Zigbee Smoke Detector', namespace: 'kkossev', author: 'Krassimir Kossev', importUrl: 'https://raw.githubusercontent.com/kkossev/Hubitat/development/Drivers/Tuya_Zigbee_Smoke_Detector/Tuya_Zigbee_Smoke_Detector.groovy', singleThreaded: true ) {
capability 'Sensor'
//capability "Configuration"
capability 'Smoke Detector' // attributes: smoke ("detected","clear","tested") ea.STATE, true, false).withDescription('Smoke alarm status'), [dp=1]
capability 'TamperAlert' // attributes: tamper - ENUM ["clear", "detected"] [dp=4 ] values 1/0
capability 'TestCapability'
capability 'Battery' // ea.STATE, ['low', 'middle', 'high']).withDescription('Battery level state'), dp14 0=25% 1=50% 2=90% [dp=14] battery low value 2 (FULL)
capability 'PowerSource' //powerSource - ENUM ["battery", "dc", "mains", "unknown"]
capability 'Health Check'
//capability "Refresh"
attribute 'healthStatus', 'enum', ['unknown', 'offline', 'online']
attribute 'rtt', 'number'
command 'clear'
command 'detected'
command 'tested'
//command "silenceSiren", [[name:"Silence Siren", type: "ENUM", description: "Silence the Siren", constraints: ["--- Select ---", "true", "false" ]]] // 'Silence the siren' ea.STATE_SET, true, false) HE->Tuya dp=16, BOOL
//command "enableAlarm", [[name:"Enable Alarm", type: "ENUM", description: "Enable the Alarm", constraints: ["--- Select ---", "true", "false" ]]] //'Enable the alarm' ea.STATE_SET, true, false HE->Tuya dp=20, ENUM, true: 0, false: 1
if (_DEBUG == true) {
command 'test', [
[name:'dpCommand', type: 'STRING', description: 'Tuya DP Command', constraints: ['STRING']],
[name:'dpValue', type: 'STRING', description: 'Tuya DP value', constraints: ['STRING']],
[name:'dpType', type: 'ENUM', constraints: ['DP_TYPE_VALUE', 'DP_TYPE_BOOL', 'DP_TYPE_ENUM'], description: 'DP data type']
]
}
fingerprint profileId:'0104', endpointId:'01', inClusters:'0004,0005,EF00,0000', outClusters:'0019,000A', model:'TS0601', manufacturer:'_TZE200_ntcy3xu1' // https:www.aliexpress.com/item/1005003951429372.html
fingerprint profileId:'0104', endpointId:'01', inClusters:'0004,0005,EF00,0000', outClusters:'0019,000A', model:'TS0601', manufacturer:'_TZE204_ntcy3xu1' // https://community.hubitat.com/t/release-tuya-zigbee-smoke-detector/104159/78?u=kkossev
fingerprint profileId:'0104', endpointId:'01', inClusters:'0004,0005,EF00,0000', outClusters:'0019,000A', model:'TS0601', manufacturer:'_TZE200_uebojraa' // KK CR2 battery // https://community.hubitat.com/t/tuya-zigbee-smart-smoke-detector-support/102471
fingerprint profileId:'0104', endpointId:'01', inClusters:'0004,0005,EF00,0000', outClusters:'0019,000A', model:'TS0601', manufacturer:'_TZE200_t5p1vj8r' // not tested
fingerprint profileId:'0104', endpointId:'01', inClusters:'0004,0005,EF00,0000', outClusters:'0019,000A', model:'TS0601', manufacturer:'_TZE200_e2bedvo9' // https://community.hubitat.com/t/beta-tuya-zigbee-smoke-detector/104159/16?u=kkossev
//
fingerprint profileId:'0104', endpointId:'01', inClusters:'0000,0004,0005,EF00', outClusters:'0019,000A', model:'TS0601', manufacturer:'_TZE200_yh7aoahi' // https://github.com/Koenkk/zigbee2mqtt/issues/11119 silence = Code 16; smoke detection state = code 1; Fault Alarm = Code 11; battery level state = code 14; battery level = Code 15;
fingerprint profileId:'0104', endpointId:'01', inClusters:'0004,0005,EF00,0000', outClusters:'0019,000A', model:'TS0601', manufacturer:'_TZE200_5d3vhjro' // 'SA12IZL'
fingerprint profileId:'0104', endpointId:'01', inClusters:'0004,0005,EF00,0000', outClusters:'0019,000A', model:'TS0601', manufacturer:'_TZE200_aycxwiau' // TuyaIasZone ?
fingerprint profileId:'0104', endpointId:'01', inClusters:'0004,0005,EF00,0000', outClusters:'0019,000A', model:'TS0601', manufacturer:'_TZE200_vzekyi4c' // TuyaIasZone ?
fingerprint profileId:'0104', endpointId:'01', inClusters:'0004,0005,EF00,0000', outClusters:'0019,000A', model:'TS0601', manufacturer:'_TZE200_m9skfctm' // https://community.hubitat.com/t/release-tuya-zigbee-smoke-detector/104159/52?u=kkossev
fingerprint profileId:'0104', endpointId:'01', inClusters:'0004,0005,EF00,0000', outClusters:'0019,000A', model:'TS0601', manufacturer:'_TZE200_dq1mfjug' // not tested
fingerprint profileId:'0104', endpointId:'01', inClusters:'0004,0005,EF00,0000', outClusters:'0019,000A', model:'TS0601', manufacturer:'_TZE200_ux5v4dbd' // not tested
fingerprint profileId:'0104', endpointId:'01', inClusters:'0004,0005,EF00,0000', outClusters:'0019,000A', model:'TS0601', manufacturer:'_TZE200_ytibqbra' // not tested
fingerprint profileId:'0104', endpointId:'01', inClusters:'0004,0005,EF00,0000', outClusters:'0019,000A', model:'TS0601', manufacturer:'_TZE200_dnz6yvl2' // not tested
fingerprint profileId:'0104', endpointId:'01', inClusters:'0004,0005,EF00,0000', outClusters:'0019,000A', model:'TS0601', manufacturer:'_TZE200_rccxox8p' // being tested by hubivlad
}
preferences {
input(name: 'logEnable', type: 'bool', title: 'Debug logging', description: 'Debug information, useful for troubleshooting. Recommended value is false', defaultValue: true)
input(name: 'txtEnable', type: 'bool', title: 'Description text logging', description: 'Display measured values in HE log page. Recommended value is true', defaultValue: true)
}
}
// Constants
@Field static final int COMMAND_TIMEOUT = 10 // Command timeout before setting healthState to offline
@Field static final Integer PRESENCE_COUNT_THRESHOLD = 13 // 3 x 4 hours + 1
@Field static final Integer DEFAULT_POLLING_INTERVAL = 3600 // 1 hour
@Field static final Integer MAX_PING_MILISECONDS = 10000 // rtt more than 10 seconds will be ignored
@Field static String UNKNOWN = 'UNKNOWN'
private getCLUSTER_TUYA() { 0xEF00 }
private getTUYA_ELECTRICIAN_PRIVATE_CLUSTER() { 0xE001 }
private getSETDATA() { 0x00 }
private getSETTIME() { 0x24 }
// tuya DP type
private getDP_TYPE_RAW() { '01' } // [ bytes ]
private getDP_TYPE_BOOL() { '01' } // [ 0/1 ]
private getDP_TYPE_VALUE() { '02' } // [ 4 byte value ]
private getDP_TYPE_STRING() { '03' } // [ N byte string ]
private getDP_TYPE_ENUM() { '04' } // [ 0-255 ]
private getDP_TYPE_BITMAP() { '05' } // [ 1,2,4 bytes ] as bits
def isTS0601() { return device.getDataValue('model') in ['TS0601'] }
def parse(String description) {
if (logEnable) { log.debug "${device.displayName } description is $description" }
checkDriverVersion()
if (state.rxCounter != null) { state.rxCounter = state.rxCounter + 1 }
setPresent() // powerSource event
if (isTuyaE00xCluster(description) == true || otherTuyaOddities(description) == true) {
return null
}
def event = [:]
try {
event = zigbee.getEvent(description)
}
catch ( e ) {
log.warn "exception caught while parsing description: ${description}"
//return null
}
if (event) {
if (event.name == 'switch' ) {
if (logEnable) { log.debug "${device.displayName} event ${event}" }
switchEvent( event.value )
}
else {
if (txtEnable) { log.warn "${device.displayName } received unhandled event ${event.name } = $event.value" }
}
//return null //event
}
else {
//List result = []
def descMap = [:]
try {
descMap = zigbee.parseDescriptionAsMap(description)
}
catch ( e ) {
log.warn "${device.displayName} exception caught while parsing descMap: ${descMap}"
//return null
}
if (logEnable) { log.debug "${device.displayName } Desc Map: $descMap" }
if (descMap.attrId != null ) {
// attribute report received
List attrData = [[cluster: descMap.cluster ,attrId: descMap.attrId, value: descMap.value, status: descMap.status]]
descMap.additionalAttrs.each {
attrData << [cluster: descMap.cluster, attrId: it.attrId, value: it.value, status: it.status]
}
attrData.each {
//def map = [:]
if (it.status == '86') {
if (logEnable) { log.warn "${device.displayName} Read attribute response: unsupported Attributte ${it.attrId} cluster ${descMap.cluster}" }
}
else if ( it.cluster == '0000' && it.attrId in ['0001', 'FFE0', 'FFE1', 'FFE2', 'FFE4', 'FFFE', 'FFDF']) {
if (it.attrId == '0001') {
if (logEnable) { log.debug "${device.displayName} Tuya check-in message (attribute ${it.attrId} reported: ${it.value})" }
def now = new Date().getTime()
def timeRunning = now.toInteger() - (state.pingTime ?: '0').toInteger()
if (timeRunning > 0 && timeRunning < MAX_PING_MILISECONDS) {
sendRttEvent()
}
}
else {
if (logEnable) { log.debug "${device.displayName} Tuya specific attribute ${it.attrId} reported: ${it.value}" } // not tested
}
}
else if ( it.cluster == '0000' ) {
if (it.attrId == '0000') {
if (logEnable) { log.debug "${device.displayName} zclVersion is : ${it.value}" }
}
else if (it.attrId == '0004') {
if (logEnable) { log.debug "${device.displayName} Manufacturer is : ${it.value}" }
}
else if (it.attrId == '0005') {
if (logEnable) { log.debug "${device.displayName} Model is : ${it.value}" }
}
else {
if (logEnable) { log.debug "${device.displayName} Cluster 0000 attribute ${it.attrId} reported: ${it.value}" }
}
}
else {
if (logEnable) { log.warn "${device.displayName} Unprocessed attribute report: cluster=${it.cluster} attrId=${it.attrId} value=${it.value} status=${it.status} data=${descMap.data}" }
}
} // for each attribute
} // if attribute report
else if (descMap.profileId == '0000') { //zdo
parseZDOcommand(descMap)
}
else if (descMap.clusterId != null && descMap.profileId == '0104') { // ZHA global command
parseZHAcommand(descMap)
}
else {
if (logEnable) { log.warn "${device.displayName} Unprocesed unknown command: cluster=${descMap.clusterId} command=${descMap.command} attrId=${descMap.attrId} value=${descMap.value} data=${descMap.data}" }
}
//return null //result
} // descMap
}
def parseZDOcommand( Map descMap ) {
switch (descMap.clusterId) {
case '0006' :
if (logEnable) { log.info "${device.displayName} Received match descriptor request, data=${descMap.data} (Sequence Number:${descMap.data[0]}, Input cluster count:${descMap.data[5]} Input cluster: 0x${descMap.data[7] + descMap.data[6]})" }
break
case '0013' : // device announcement
if (logEnable) { log.info "${device.displayName} Received device announcement, data=${descMap.data} (Sequence Number:${descMap.data[0]}, Device network ID: ${descMap.data[2] + descMap.data[1]}, Capability Information: ${descMap.data[11]})" }
break
case '8001' : // Device and Service Discovery - IEEE_addr_rsp
if (logEnable) { log.info "${device.displayName} Received Device and Service Discovery - IEEE_addr_rsp, data=${descMap.data} (Sequence Number:${descMap.data[0]}, Device network ID: ${descMap.data[2] + descMap.data[1]}, Capability Information: ${descMap.data[11]})" }
break
break
case '8004' : // simple descriptor response
if (logEnable) { log.info "${device.displayName} Received simple descriptor response, data=${descMap.data} (Sequence Number:${descMap.data[0]}, status:${descMap.data[1]}, lenght:${hubitat.helper.HexUtils.hexStringToInt(descMap.data[4])}" }
parseSimpleDescriptorResponse( descMap )
break
case '8005' : // endpoint response
if (logEnable) { log.info "${device.displayName} Received endpoint response: cluster: ${descMap.clusterId} (endpoint response) endpointCount = ${ descMap.data[4]} endpointList = ${descMap.data[5]}" }
break
case '8021' : // bind response
if (logEnable) { log.info "${device.displayName} Received bind response, data=${descMap.data} (Sequence Number:${descMap.data[0]}, Status: ${descMap.data[1] == '00' ? 'Success' : 'Failure'})" }
break
case '8038' : // Management Network Update Notify
if (logEnable) { log.info "${device.displayName} Received Management Network Update Notify, data=${descMap.data}" }
break
default :
if (logEnable) { log.warn "${device.displayName} Unprocessed ZDO command: cluster=${descMap.clusterId} command=${descMap.command} attrId=${descMap.attrId} value=${descMap.value} data=${descMap.data}" }
}
}
def parseSimpleDescriptorResponse(Map descMap) {
//log.info "${device.displayName} Received simple descriptor response, data=${descMap.data} (Sequence Number:${descMap.data[0]}, status:${descMap.data[1]}, lenght:${hubitat.helper.HexUtils.hexStringToInt(descMap.data[4])}"
if (logEnable) { log.info "${device.displayName} Endpoint: ${descMap.data[5]} Application Device:${descMap.data[9]}${descMap.data[8]}, Application Version:${descMap.data[10]}" }
def inputClusterCount = hubitat.helper.HexUtils.hexStringToInt(descMap.data[11])
def inputClusterList = ''
for (int i in 1..inputClusterCount) {
inputClusterList += descMap.data[13 + (i - 1) * 2] + descMap.data[12 + (i - 1) * 2] + ','
}
inputClusterList = inputClusterList.substring(0, inputClusterList.length() - 1)
if (logEnable) { log.info "${device.displayName} Input Cluster Count: ${inputClusterCount} Input Cluster List : ${inputClusterList}" }
if (getDataValue('inClusters') != inputClusterList) {
if (logEnable) { log.warn "${device.displayName} inClusters=${getDataValue('inClusters')} differs from inputClusterList:${inputClusterList} - will be updated!" }
updateDataValue('inClusters', inputClusterList)
}
def outputClusterCount = hubitat.helper.HexUtils.hexStringToInt(descMap.data[12 + inputClusterCount * 2])
def outputClusterList = ''
for (int i in 1..outputClusterCount) {
outputClusterList += descMap.data[14 + inputClusterCount * 2 + (i - 1) * 2] + descMap.data[13 + inputClusterCount * 2 + (i - 1) * 2] + ','
}
outputClusterList = outputClusterList.substring(0, outputClusterList.length() - 1)
if (logEnable) { log.info "${device.displayName} Output Cluster Count: ${outputClusterCount} Output Cluster List : ${outputClusterList}" }
if (getDataValue('outClusters') != outputClusterList) {
if (logEnable) { log.warn "${device.displayName} outClusters=${getDataValue('outClusters')} differs from outputClusterList:${outputClusterList} - will be updated!" }
updateDataValue('outClusters', outputClusterList)
}
}
def parseZHAcommand( Map descMap) {
switch (descMap.command) {
case '01' : //read attribute response. If there was no error, the successful attribute reading would be processed in the main parse() method.
case '02' :
def status = descMap.data[2]
def attrId = descMap.data[1] + descMap.data[0]
if (status == '86') {
if (logEnable) { log.warn "${device.displayName} Read attribute response: unsupported Attributte ${attrId} cluster ${descMap.clusterId} descMap = ${descMap}" }
}
else {
switch (descMap.clusterId) {
case 'EF00' :
if (logEnable) { log.debug "${device.displayName} Tuya cluster read attribute response: code ${status} Attributte ${attrId} cluster ${descMap.clusterId} data ${descMap.data}" }
def cmd = descMap.data[2]
def value = getAttributeValue(descMap.data)
//if (logEnable==true) log.trace "${device.displayName} Tuya cluster cmd=${cmd} value=${value} ()"
//def map = [:]
switch (cmd) {
case '01' : // smoke alarm for all models
if (txtEnable) { log.info "${device.displayName} smoke alarm (dp=${cmd}) is: ${value}" }
sendSmokeAlarmEvent( value)
break
case '02' : // raw data from _TZE200_m9skfctm '_TZE200_e2bedvo9', '_TZE200_dnz6yvl2'
if (txtEnable) { log.info "${device.displayName} smoke concentration (dp=${cmd}) is: ${value / 10}ppm (${value})" }
break
case '04' : // "TamperAlert" for all models
if (txtEnable) { log.info "${device.displayName} tamper alert (dp=${cmd}) is: ${value}" }
sendTamperAlertEvent( value )
break
case '0B' : // (11) "Fault Alarm" for _TZE200_yh7aoahi _TZE200_m9skfctm
if (txtEnable) { log.info "${device.displayName} Fault Alarm (dp=${cmd}) is: ${value}" }
break
case '0E' : // (14) "battery level state" ['low', 'middle', 'high'] dp14 0=25% 1=50% 2=90% also for _TZE200_yh7aoahi
if (txtEnable) { log.info "${device.displayName} Battery level state (dp=${cmd}) is: ${value}" }
sendBatteryStateEvent( value )
break
case '0F' : // (15) "battery level % for _TZE200_yh7aoahi
if (txtEnable) { log.info "${device.displayName} Battery level % (dp=${cmd}) is: ${value}%" }
sendBatteryPercentEvent( value )
break
case '10' : // (16) "silence" for _TZE200_yh7aoahi _TZE200_ytibqbra
if (txtEnable) { log.info "${device.displayName} 'silence' state (dp=${cmd}) is: ${value}" }
break
case '11' : // (17) "alarm" for _TZE200_ytibqbra
if (txtEnable) { log.info "${device.displayName} 'alarm' state (dp=${cmd}) is: ${value}" }
break
case '65' : // (101) test for _TZE200_m9skfctm; alarm for _TZE200_dq1mfjug
if (device.getDataValue('manufacturer') in ['_TZE200_m9skfctm']) {
if (txtEnable) { log.info "${device.displayName} test (dp=${cmd}) is: ${value}" }
sendSmokeAlarmEvent(2)
}
else {
if (txtEnable) { log.info "${device.displayName} smoke alarm (dp=${cmd}) is: ${value}" }
sendSmokeAlarmEvent(value)
}
break
default :
if (logEnable) { log.warn "Tuya unknown attribute: ${descMap.data[0]}${descMap.data[1]}=${descMap.data[2]}=${descMap.data[3]}${descMap.data[4]} data.size() = ${descMap.data.size()} value: ${value}}" }
if (logEnable) { log.warn "map= ${descMap}" }
break
}
break
default :
if (logEnable) { log.warn "${device.displayName} Read attribute response: unknown status code ${status} Attributte ${attrId} cluster ${descMap.clusterId}" }
break
} // switch (descMap.clusterId)
} //command is read attribute response 01 or 02 (Tuya)
break
case '07' : // Configure Reporting Response
if (logEnable) { log.info "${device.displayName} Received Configure Reporting Response for cluster:${descMap.clusterId} , data=${descMap.data} (Status: ${descMap.data[0] == '00' ? 'Success' : 'Failure'})" }
// Status: Unreportable Attribute (0x8c)
break
case '0B' : // ZCL Default Response
def status = descMap.data[1]
if (status != '00') {
switch (descMap.clusterId) {
case '0006' : // Switch state
if (logEnable) { log.warn "${device.displayName} Switch state is not supported -> Switch polling will be disabled." }
state.switchPollingSupported = false
break
default :
if (logEnable) { log.info "${device.displayName} Received ZCL Default Response to Command ${descMap.data[0]} for cluster:${descMap.clusterId} , data=${descMap.data} (Status: ${descMap.data[1] == '00' ? 'Success' : 'Failure'})" }
break
}
}
break
case '11' : // Tuya specific
if (logEnable) { log.info "${device.displayName} Tuya specific command: cluster=${descMap.clusterId} command=${descMap.command} data=${descMap.data}" }
break
case '24' : // Tuya time sync
//log.trace "Tuya time sync"
if (descMap?.clusterInt == 0xEF00 && descMap?.command == '24') { //getSETTIME
if (settings?.logEnable) { log.debug "${device.displayName} time synchronization request from device, descMap = ${descMap}" }
def offset = 0
try {
offset = location.getTimeZone().getOffset(new Date().getTime())
//if (settings?.logEnable) log.debug "${device.displayName} timezone offset of current location is ${offset}"
}
catch (e) {
if (settings?.logEnable) { log.error "${device.displayName} cannot resolve current location. please set location in Hubitat location setting. Setting timezone offset to zero" }
}
def cmds = zigbee.command(0xEF00, 0x24, '0008' + zigbee.convertToHexString((int)(now() / 1000), 8) + zigbee.convertToHexString((int)((now() + offset) / 1000), 8))
if (settings?.logEnable) { log.trace "${device.displayName} now is: ${now()}" } // KK TODO - convert to Date/Time string!
if (settings?.logEnable) { log.debug "${device.displayName} sending time data : ${cmds}" }
cmds.each {
sendHubCommand(new hubitat.device.HubAction(it, hubitat.device.Protocol.ZIGBEE))
}
return
}
break
default :
if (logEnable) { log.warn "${device.displayName} Unprocessed global command: cluster=${descMap.clusterId} command=${descMap.command} attrId=${descMap.attrId} value=${descMap.value} data=${descMap.data}" }
}
}
/* groovylint-disable-next-line ImplementationAsType */
private int getAttributeValue(ArrayList _data) {
int retValue = 0
try {
if (_data.size() >= 6) {
int dataLength = zigbee.convertHexToInt(_data[5]) as Integer
int power = 1
for (i in dataLength..1) {
retValue = retValue + power * zigbee.convertHexToInt(_data[i + 5])
power = power * 256
}
}
}
catch (e) {
log.error "${device.displayName} Exception caught : data = ${_data}"
}
return retValue
}
def sendSmokeAlarmEvent( value, isDigital=false ) { // attributes: smoke ("detected","clear","tested") ea.STATE, true, false).withDescription('Smoke alarm status'), [dp=1]
def map = [:]
map.value = value == 0 ? 'detected' : value == 1 ? 'clear' : value == 2 ? 'tested' : null
map.name = 'smoke'
map.unit = ''
map.type = isDigital == true ? 'digital' : 'physical'
map.isStateChange = true
map.descriptionText = "${map.name} is ${map.value}"
if (settings?.txtEnable) { log.info "${device.displayName } ${map.descriptionText }" }
sendEvent(map)
}
def sendTamperAlertEvent( value, isDigital=false ) { // attributes: tamper - ENUM ["clear", "detected"] [dp=4 ] values 1/0
def map = [:]
map.value = value == 0 ? 'clear' : value == 1 ? 'detected' : null
map.name = 'tamper'
map.unit = ''
map.type = isDigital == true ? 'digital' : 'physical'
map.isStateChange = true
map.descriptionText = "${map.name} is ${map.value}"
if (settings?.txtEnable) { log.info "${device.displayName } ${map.descriptionText }" }
sendEvent(map)
}
def sendBatteryStateEvent( value, isDigital=false ) { // ea.STATE, ['low', 'middle', 'high']).withDescription('Battery level state'), dp14 0=25% 1=50% 2=90% [dp=14] battery low value 2 (FULL)
def percent = value == 0 ? 25 : value == 1 ? 50 : value == 2 ? 100 : value
sendBatteryPercentEvent( percent, isDigital )
}
def sendBatteryPercentEvent( value, isDigital=false ) {
def map = [:]
map.value = value > 100 ? 100 : value
map.name = 'battery'
map.unit = '%'
map.type = isDigital == true ? 'digital' : 'physical'
map.isStateChange = true
map.descriptionText = "${map.name} is ${map.value} ${map.unit}"
if (settings?.txtEnable) { log.info "${device.displayName } ${map.descriptionText }" }
sendEvent(map)
}
def silenceSiren( state ) { // command "silenceSiren" 'Silence the siren' ea.STATE_SET, true, false) HE->Tuya dp=16, BOOL
if (logEnable) { log.debug "${device.displayName } silenceSiren ${state }" }
ArrayList cmds = []
def dpVal = state == 'true' ? 1 : state == 'false' ? 0 : null
if (dpVal != null) {
def dpValHex = zigbee.convertToHexString(dpVal, 2)
cmds = sendTuyaCommand('10', DP_TYPE_BOOL, dpValHex)
sendZigbeeCommands( cmds )
}
else {
if (txtEnable) { log.warn "${device.displayName } silenceSiren : please select true or false" }
}
}
def enableAlarm( state ) { // command "enableAlarm" //'Enable the alarm' ea.STATE_SET, true, false HE->Tuya dp=20, ENUM, true: 0, false: 1
if (logEnable) { log.debug "${device.displayName} silenceSiren ${state }" }
ArrayList cmds = []
def dpVal = state == 'true' ? 1 : state == 'false' ? 0 : null
if (dpVal != null) {
def dpValHex = zigbee.convertToHexString(dpVal, 2)
cmds = sendTuyaCommand('14', DP_TYPE_ENUM, dpValHex)
sendZigbeeCommands( cmds )
}
else {
if (txtEnable) { log.warn "${device.displayName} enableAlarm : please select true or false" }
}
}
def sendBatteryEvent( roundedPct, isDigital=false ) {
sendEvent(name: 'battery', value: roundedPct, unit: '%', type: isDigital == true ? 'digital' : 'physical', isStateChange: true )
}
def clear() {
sendSmokeAlarmEvent( 1, isDigital = true )
}
def detected() {
sendSmokeAlarmEvent( 0, isDigital = true )
}
def tested() {
sendSmokeAlarmEvent( 2, isDigital = true )
}
def refresh() {
logInfo('refresh()...')
List cmds = []
checkDriverVersion()
cmds += zigbee.command(0xEF00, 0x10, '0002')
scheduleCommandTimeoutCheck()
state.pingTime = new Date().getTime()
sendZigbeeCommands(cmds)
}
def ping() {
logInfo 'ping...'
scheduleCommandTimeoutCheck()
state.pingTime = new Date().getTime()
sendZigbeeCommands( zigbee.readAttribute(zigbee.BASIC_CLUSTER, 0x01, [:], 0) )
}
def sendRttEvent() {
def now = new Date().getTime()
def timeRunning = now.toInteger() - (state.pingTime ?: '0').toInteger()
def descriptionText = "Round-trip time is ${timeRunning} (ms)"
logInfo "${descriptionText}"
sendEvent(name: 'rtt', value: timeRunning, descriptionText: descriptionText, unit: 'ms', isDigital: true)
}
private void scheduleCommandTimeoutCheck(int delay = COMMAND_TIMEOUT) {
runIn(delay, 'deviceCommandTimeout')
}
private void scheduleDeviceHealthCheck(int intervalMins) {
Random rnd = new Random()
schedule("${rnd.nextInt(59)} ${rnd.nextInt(9)}/${intervalMins} * ? * * *", 'ping')
}
void deviceCommandTimeout() {
logWarn 'no response received (sleepy device or offline?)'
}
def tuyaBlackMagic() {
List cmds = []
cmds += zigbee.readAttribute(0x0000, [0x0004, 0x000, 0x0001, 0x0005, 0x0007, 0xfffe], [:], delay = 200) // Cluster: Basic, attributes: Man.name, ZLC ver, App ver, Model Id, Power Source, attributeReportingStatus
cmds += zigbee.writeAttribute(0x0000, 0xffde, 0x20, 0x0d, [:], delay = 50)
return cmds
}
/*
configure() method is called:
* unconditionally during the initial pairing, immediately after Installed() method
* when Initialize button is pressed
* from updated() when preferencies are saved
*/
def configure() {
if (txtEnable) { log.info "${device.displayName} configure().." }
runIn( DEFAULT_POLLING_INTERVAL, pollPresence, [overwrite: true])
List cmds = []
cmds += tuyaBlackMagic()
sendZigbeeCommands(cmds)
}
// This method is called when the preferences of a device are updated.
def updated() {
if (txtEnable) { log.info "Updating ${device.getLabel()} (${device.getName()}) model ${state.model} " }
if (txtEnable) { log.info "${device.displayName} Debug logging is ${logEnable} Description text logging is ${txtEnable}" }
if (logEnable) {
runIn(86400, logsOff, [overwrite: true])
if (txtEnable) { log.info "${device.displayName} Debug logging will be automatically switched off after 24 hours" }
}
else {
unschedule(logsOff)
}
if (txtEnable) { log.info 'updated()...' }
configure()
}
void initializeVars( boolean fullInit = true ) {
if (txtEnable) { log.info "${device.displayName} InitializeVars()... fullInit = ${fullInit}" }
if (fullInit == true ) {
state.clear()
state.driverVersion = driverVersionAndTimeStamp()
}
state.packetID = 0
state.rxCounter = 0
state.txCounter = 0
if (fullInit == true || state.notPresentCounter == null) { state.notPresentCounter = 0 }
if (fullInit == true || state.isDigital == null) { state.isDigital = true }
if (fullInit == true || device.getDataValue('logEnable') == null) { device.updateSetting('logEnable', true) }
if (fullInit == true || device.getDataValue('txtEnable') == null) { device.updateSetting('txtEnable', true) }
def mm = device.getDataValue('model')
if ( mm != null) {
state.model = mm
if (logEnable) { log.trace " model = ${state.model}" }
}
else {
if (txtEnable) { log.warn ' Model not found, please re-pair the device!' }
state.model = UNKNOWN
}
def ep = device.getEndpointId()
if ( ep != null) {
//state.destinationEP = ep
if (logEnable) { log.trace " destinationEP = ${ep}" }
}
else {
if (txtEnable) { log.warn ' Destination End Point not found, please re-pair the device!' }
//state.destinationEP = "01" // fallback
}
}
def driverVersionAndTimeStamp() { version() + ' ' + timeStamp() }
def checkDriverVersion() {
if (state.driverVersion != driverVersionAndTimeStamp()) {
if (txtEnable) { log.debug "updating the settings from the current driver version ${state.driverVersion} to the new version ${driverVersionAndTimeStamp()}" }
initializeVars(fullInit = false)
state.driverVersion = driverVersionAndTimeStamp()
}
}
def logInitializeRezults() {
if (logEnable) { log.info "${device.displayName} Initialization finished" }
}
def initialize() {
if (txtEnable) { log.info "${device.displayName} Initialize()..." }
unschedule()
initializeVars(fullInit = false)
updated() // calls also configure()
runIn( 5, logInitializeRezults, [overwrite: true])
}
// This method is called when the device is first created.
def installed() {
if (txtEnable) { log.info "${device.displayName} Installed()..." }
initializeVars()
def descText = 'driver just installed'
sendEvent(name: 'smoke', value: 'unknown', descriptionText: descText, type: 'digital' , isStateChange: true )
sendEvent(name: 'healthStatus', value: 'unknown', descriptionText: descText, type: 'digital' , isStateChange: true )
sendEvent(name: 'powerSource', value: 'unknown', descriptionText: descText, type: 'digital' , isStateChange: true )
runIn( 5, initialize, [overwrite: true])
if (logEnable) { log.debug 'calling initialize() after 5 seconds...' }
// HE will autoomaticall call configure() method here
}
void uninstalled() {
if (logEnable) { log.info "${device.displayName} Uninstalled()..." }
unschedule() //Unschedule any existing schedules
}
// called when any event was received from the Zigbee device in parse() method..
def setPresent() {
if ((device.currentValue('healthStatus', true) ?: '') != 'online') {
sendHealthStatusEvent('online')
sendEvent(name: 'powerSource', value: 'battery')
}
state.notPresentCounter = 0
unschedule('deviceCommandTimeout')
}
// called from pollPresence()
def checkIfNotPresent() {
if (state.notPresentCounter != null) {
state.notPresentCounter = state.notPresentCounter + 1
if (state.notPresentCounter >= PRESENCE_COUNT_THRESHOLD) {
if ((device.currentValue('healthStatus', true) ?: '') != 'offline') {
sendHealthStatusEvent('offline')
}
logWarn 'is not present!'
}
}
else {
state.notPresentCounter = 1
}
}
// check for device offline every 60 minutes
def pollPresence() {
logDebug 'pollPresence()...'
checkIfNotPresent()
runIn( DEFAULT_POLLING_INTERVAL, pollPresence, [overwrite: true])
}
def sendHealthStatusEvent(value) {
//log.trace "healthStatus ${value}"
def descriptionText = "healthStatus set to ${value}"
logInfo "${descriptionText}"
sendEvent(name: 'healthStatus', value: value, descriptionText: descriptionText)
}
private getPACKET_ID() {
state.packetID = ((state.packetID ?: 0) + 1 ) % 65536
return zigbee.convertToHexString(state.packetID, 4)
}
private sendTuyaCommand(dp, dp_type, fncmd) {
ArrayList cmds = []
cmds += zigbee.command(CLUSTER_TUYA, SETDATA, [:], delay = 200, PACKET_ID + dp + dp_type + zigbee.convertToHexString((int)(fncmd.length() / 2), 4) + fncmd )
if (settings?.logEnable) { log.trace "${device.displayName} sendTuyaCommand = ${cmds}" }
if (state.txCounter != null) { state.txCounter = state.txCounter + 1 }
return cmds
}
/* groovylint-disable-next-line ImplementationAsType */
void sendZigbeeCommands(ArrayList cmd) {
if (settings?.logEnable) { log.debug "${device.displayName } sendZigbeeCommands (cmd=$cmd)" }
hubitat.device.HubMultiAction allActions = new hubitat.device.HubMultiAction()
cmd.each {
allActions.add(new hubitat.device.HubAction(it, hubitat.device.Protocol.ZIGBEE))
if (state.txCounter != null) { state.txCounter = state.txCounter + 1 }
}
sendHubCommand(allActions)
}
def logsOff() {
log.warn 'debug logging disabled...'
device.updateSetting('logEnable', [value:'false', type:'bool'])
}
boolean isTuyaE00xCluster( String description ) {
if (!(description.indexOf('cluster: E000') >= 0 || description.indexOf('cluster: E001') >= 0)) {
return false
}
// try to parse ...
if (logEnable) { log.debug "${device.displayName} Tuya cluster: E000 or E001 - try to parse it..." }
def descMap = [:]
try {
descMap = zigbee.parseDescriptionAsMap(description)
}
catch ( e ) {
log.warn "${device.displayName} exception caught while parsing description: ${description}"
if (logEnable) { log.debug "${device.displayName} TuyaE00xCluster Desc Map: ${descMap}" }
// cluster E001 is the one that is generating exceptions...
return true
}
if (logEnable) { log.debug "${device.displayName} TuyaE00xCluster Desc Map: $descMap" }
//
return true
}
/* groovylint-disable-next-line UnusedMethodParameter */
boolean otherTuyaOddities( String description ) {
return false
}
def logDebug(msg) {
if (settings?.logEnable) {
log.debug "${device.displayName} " + msg
}
}
def logInfo(msg) {
if (settings?.txtEnable) {
log.info "${device.displayName} " + msg
}
}
def logWarn(msg) {
if (settings?.logEnable) {
log.warn "${device.displayName} " + msg
}
}
def test( dpCommand, dpValue, dpTypeString ) {
//ArrayList cmds = []
def dpType = dpTypeString == 'DP_TYPE_VALUE' ? DP_TYPE_VALUE : dpTypeString == 'DP_TYPE_BOOL' ? DP_TYPE_BOOL : dpTypeString == 'DP_TYPE_ENUM' ? DP_TYPE_ENUM : null
def dpValHex = dpTypeString == 'DP_TYPE_VALUE' ? zigbee.convertToHexString(dpValue as int, 8) : dpValue
log.warn " sending TEST command=${dpCommand} value=${dpValue} ($dpValHex) type=${dpType}"
sendZigbeeCommands( sendTuyaCommand(dpCommand, dpType, dpValHex) )
}