/**
* Tuya / NEO Coolcam Zigbee Water Leak Sensor driver for Hubitat
*
* https://community.hubitat.com/t/release-tuya-neo-coolcam-zigbee-water-leak-sensor/91370
*
* 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-03-26 kkossev - Inital test version
* ver. 1.0.1 2022-04-12 kkossev - added _TYST11_qq9mpfhw fingerprint
* ver. 1.0.2 2022-04-14 kkossev - Check-in info logs; model 'q9mpfhw' inClusters correction
* ver. 1.0.3 2022-04-16 kkossev - 'Last Updated' workaround for NEO sensors
* ver. 1.0.4 2022-05-14 kkossev - code cleanup; debug logging is off by default; fixed debug logging not turning off after 24 hours; added Configure button
* ver. 1.0.5 2022-08-03 kkossev - added batterySource, added watchDog, set battery 0% if OFFLINE
* ver. 1.0.6 2022-11-15 kkossev - fixed _TZ3000_qdmnmddg fingerprint; added _TZ3000_rurvxhcx ; added _TZ3000_kyb656no ;
* ver. 1.0.7 2022-11-20 kkossev - offline timeout increased to 12 hours; Import button loads the dev. branch version; Configure will not reset power source to '?'; Save Preferences will update the driver version state; water is set to 'unknown' when offline
* added lastWaterWet time in human readable format; added device rejoinCounter state; water is set to 'unknown' when offline; added feibit FNB56-WTS05FB2.0; added 'tested' water state; pollPresence misfire after hub reboot bug fix
* added Momentary capability - push() button will generate a 'tested' event for 2 seconds; added Presence capability;
* ver. 1.0.8 2023-05-13 kkossev - 'unprocessed water event unknown' fix; lastWaterWet update bug fix;
* ver. 1.1.0 2023-07-11 kkossev - replaced Presence w/ healthStatus; added TS0207 _TZ3000_js34cuma; removed manipulating the powerSource and dropping the battery level 0% when offline.
* ver. 1.1.1 2023-07-28 kkossev - (dev. branch) added ping rtt; added zigbee.enrollResponse() in the configuration; added TS0207 _TZ3000_2wcynpml
*
* TODO: scheduleCommandTimeoutCheck()
* TODO: check why Neo Coolcam is not sending actual battery reports : https://community.hubitat.com/t/release-tuya-neo-coolcam-zigbee-water-leak-sensor/91370/86?u=kkossev
* TODO: add batteryLastReplaced event; add 'Testing option'; add 'isTesting' state;
*
*/
def version() { "1.1.1" }
def timeStamp() {"2023/07/28 11:31 AM"}
@Field static final Boolean debug = false
@Field static final Boolean debugLogsDefault = true
import groovy.json.*
import groovy.transform.Field
import hubitat.zigbee.zcl.DataType
import hubitat.device.HubAction
import hubitat.device.Protocol
import hubitat.zigbee.clusters.iaszone.ZoneStatus
metadata {
definition (name: "Tuya NEO Coolcam Zigbee Water Leak Sensor", namespace: "kkossev", author: "Krassimir Kossev", importUrl: "https://raw.githubusercontent.com/kkossev/Hubitat/development/Drivers/Tuya%20NEO%20Coolcam%20Zigbee%20Water%20Leak%20Sensor/Tuya%20NEO%20Coolcam%20Zigbee%20Water%20Leak%20Sensor.groovy", singleThreaded: true ) {
capability "Sensor"
capability "Battery"
capability "WaterSensor"
capability "PowerSource"
capability "TestCapability"
capability "Momentary"
capability "Health Check" // replaced capability "PresenceSensor"
//capability "TamperAlert" // tamper - ENUM ["clear", "detected"]
attribute 'healthStatus', 'enum', ['unknown', 'offline', 'online']
attribute "rtt", "number"
command "configure", [[name: "Manually initialize the sensor after switching drivers. \n\r ***** Will load the device default values! *****" ]]
command "wet", [[name: "Manually switch the Water Leak Sensor to WET state" ]]
command "dry", [[name: "Manually switch the Water Leak Sensor to DRY state" ]]
command "push", [[name: "Manually switch the Water Leak Sensor to TESTED state" ]]
if (debug==true) {
command "test"
}
fingerprint profileId:"0104", endpointId:"01", inClusters:"0000,0004,0005,EF00", outClusters:"0019,000A", model:"TS0601", manufacturer:"_TZE200_qq9mpfhw", deviceJoinName: "NEO Coolcam Leak Sensor" // vendor: 'Neo', model: 'NAS-WS02B0', 'NAS-DS07'
fingerprint profileId:"0104", endpointId:"01", inClusters:"0000,0003", outClusters:"0003,0019", model:"q9mpfhw",manufacturer:"_TYST11_qq9mpfhw", deviceJoinName: "NEO Coolcam Leak Sensor SNTZ009" // SNTZ009
fingerprint profileId:"0104", endpointId:"01", inClusters:"0000,0004,0005,EF00", outClusters:"0019,000A", model:"TS0601", manufacturer:"_TZE200_jthf7vb6", deviceJoinName: "Tuya Leak Sensor TS0601" // vendor: 'TuYa', model: 'WLS-100z'
fingerprint profileId:"0104", endpointId:"01", inClusters:"0000,0001,0003,0500,EF01", outClusters:"0003,0019", model:"TS0207", manufacturer:"_TYZB01_sqmd19i1", deviceJoinName: "Tuya Leak Sensor TS0207 Type I" // round cabinet, sensors on the bottom
fingerprint profileId:"0104", endpointId:"01", inClusters:"0000,0001,0003,0500,EF01", outClusters:"0003,0019", model:"TS0207", manufacturer:"_TYZB01_o63ssaah", deviceJoinName: "Blitzwolf Leak Sensor BW-IS5"
fingerprint profileId:"0104", endpointId:"01", inClusters:"0001,0003,0500,0000", outClusters:"0019,000A", model:"TS0207", manufacturer:"_TZ3000_upgcbody", deviceJoinName: "Tuya Leak Sensor TS0207 Type II" // rerctangular cabinet, external sensor; +BatteryLowAlarm!?
fingerprint profileId:"0104", endpointId:"01", inClusters:"0001,0003,0500,0000", outClusters:"0019,000A", model:"TS0207", manufacturer:"_TZ3000_t6jriawg", deviceJoinName: "Moes Leak Sensor TS0207" // Moes
fingerprint profileId:"0104", endpointId:"01", inClusters:"0000,000A,0500,0001", outClusters:"0019", model:"TS0207", manufacturer:"_TZ3000_qdmnmddg", deviceJoinName: "Tuya Leak Sensor TS0207 Type II" // https://community.hubitat.com/t/aliexpress-has-flash-sale-on-tuya-zigbee-leak-sensor-9-28/93727/3?u=kkossev
fingerprint profileId:"0104", endpointId:"01", inClusters:"0001,0003,0500,0000", outClusters:"0019,000A", model:"TS0207", manufacturer:"_TZ3000_rurvxhcx", deviceJoinName: "Tuya Leak Sensor TS0207 Type III" // https://community.hubitat.com/t/aliexpress-has-flash-sale-on-tuya-zigbee-leak-sensor-9-28/93727/13?u=kkossev
fingerprint profileId:"0104", endpointId:"01", inClusters:"0001,0003,0500,0000", outClusters:"0019,000A", model:"TS0207", manufacturer:"_TZ3000_kyb656no", deviceJoinName: "MEIAN Water Leak Sensor" // https://community.hubitat.com/t/release-tuya-neo-coolcam-zigbee-water-leak-sensor/91370/22?u=kkossev
fingerprint profileId:"0104", endpointId:"01", inClusters:"0000,0003,000A,0019,0001,0500,0501,1000", outClusters:"0004,0003,0001,0500,0501", model:"FNB56-WTS05FB2.0", manufacturer:"feibit", deviceJoinName: "Feibit SWA01ZB Water Leakage Sensor" // https://community.hubitat.com/t/release-tuya-neo-coolcam-zigbee-water-leak-sensor/91370/41?u=kkossev
fingerprint profileId:"0104", endpointId:"01", inClusters:"0000,0003,000A,0019,0001,0500,0501,1000", outClusters:"0004,0003,0001,0500,0501", model:"FNB56-WTS05FB2.4", manufacturer:"feibit", deviceJoinName: "Feibit SWA01ZB Water Leakage Sensor" // not tested
fingerprint profileId:"0104", endpointId:"01", inClusters:"0001,0003,0500,0000", outClusters:"0019,000A", model:"TS0207", manufacturer:"_TZ3000_js34cuma", deviceJoinName: "Tuya Leak Sensor TS0207 Type II" // KK
fingerprint profileId:"0104", endpointId:"01", inClusters:"0001,0003,0500,0000", outClusters:"0019,000A", model:"TS0207", manufacturer:"_TZ3000_2wcynpml", deviceJoinName: "Tuya Leak Sensor TS0207" // https://community.hubitat.com/t/2nd-water-sensor-will-not-report/122112?u=kkossev
}
preferences {
input (name: "logEnable", type: "bool", title: "Debug logging", description: "Debug information, useful for troubleshooting. Recommended value is false", defaultValue: debugLogsDefault)
input (name: "txtEnable", type: "bool", title: "Description text logging", description: "Display sensor states in HE log page. Recommended value is true", defaultValue: true)
/*
input (name: "testingDelay", type: "bool", title: "Enable Delayed Testing", description: "If state stays 'wet' for less than NN seconds, assume this was a test", defaultValue: false)
if (testingDelay?.value == true) {
input (name: "minimumWetTime", type: "number", title: "Minimum 'wet' time", description: "The minimum time the leak sensor must report 'wet' state to be considered as a real alarm. Default = 3 seconds", range: "0..7200", defaultValue: 3)
}
*/
}
}
// 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 getSETDATA() { 0x00 }
private getSETTIME() { 0x24 }
// Tuya Commands
private getTUYA_REQUEST() { 0x00 }
private getTUYA_REPORTING() { 0x01 }
private getTUYA_QUERY() { 0x02 }
private getTUYA_STATUS_SEARCH() { 0x06 }
private getTUYA_TIME_SYNCHRONISATION() { 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
// Parse incoming device messages to generate events
def parse(String description) {
checkDriverVersion()
if (state.rxCounter != null) state.rxCounter = state.rxCounter + 1
setPresent()
//if (settings?.logEnable == true) log.debug "${device.displayName} parse() descMap = ${zigbee.parseDescriptionAsMap(description)}"
if (description?.startsWith('catchall:') || description?.startsWith('read attr -')) {
Map descMap = zigbee.parseDescriptionAsMap(description)
if (descMap.clusterInt == 0x0001 && descMap.commandInt != 0x07 && descMap?.value) {
if (descMap.attrInt == 0x0021) {
getBatteryPercentageResult(Integer.parseInt(descMap.value,16))
} else if (descMap.attrInt == 0x0020){
getBatteryResult(Integer.parseInt(descMap.value, 16))
}
else {
logWarn "unparsed power cluster attrint ${descMap.attrInt}"
}
}
else if (descMap?.clusterInt == CLUSTER_TUYA) {
processTuyaCluster( descMap )
}
else if (descMap?.clusterId == "0013") { // device announcement, profileId:0000
state.rejoinCounter = (state.rejoinCounter ?: 0) + 1
logDebug "device announcement"
}
else if (descMap?.cluster == "0000" && descMap?.attrId == "0001") {
logDebug "Tuya check-in (0001) app version ${descMap?.value}"
if ((state.pingTime ?: '0').toInteger() > 0) {
def now = new Date().getTime()
def timeRunning = now.toInteger() - (state.pingTime ?: '0').toInteger()
if (timeRunning > 0 && timeRunning < MAX_PING_MILISECONDS) {
sendRttEvent()
}
state.pingTime = null
}
}
else if (descMap?.cluster == "0000" && descMap?.attrId in ["FFDF", "FFE2", "FFE4","FFFE"]) {
logDebug "Tuya check-in (${descMap?.attrId}) value is ${descMap?.value}"
}
else if (descMap?.cluster == "0500" && descMap?.command == "01") { //read attribute response
logDebug "IAS read attribute ${descMap?.attrId} response is ${descMap?.value}"
}
else if (descMap?.clusterId == "0500" && descMap?.command == "04") { //write attribute response
logDebug "IAS enroll write attribute response is ${descMap?.data[0] == "00" ? "success" : "failure"}"
}
else {
logDebug " NOT PARSED : descMap = ${descMap}"
}
} // if 'catchall:' or 'read attr -'
else if (description?.startsWith('zone status') || description?.startsWith('zone report')) {
logDebug "Zone status: $description"
parseIasMessage(description)
}
else if (description?.startsWith('enroll request')) {
/* The Zone Enroll Request command is generated when a device embodying the Zone server cluster wishes to be enrolled as an active alarm device. It must do this immediately it has joined the network (during commissioning). */
logDebug "Sending IAS enroll response..."
ArrayList cmds = zigbee.enrollResponse() + zigbee.readAttribute(0x0500, 0x0000)
logDebug "enroll response: ${cmds}"
sendZigbeeCommands( cmds )
}
else {
logDebug " UNPROCESSED description = ${description} descMap = ${zigbee.parseDescriptionAsMap(description)}"
}
}
def parseIasMessage(String description) {
// https://developer.tuya.com/en/docs/iot-device-dev/tuya-zigbee-water-sensor-access-standard?id=K9ik6zvon7orn
try {
Map zs = zigbee.parseZoneStatusChange(description)
//if (settings?.logEnable == true) log.trace "zs = $zs"
processWaterEvent( zs.alarm1Set == true ? 'wet' : 'dry')
}
catch (e) {
log.error "This driver requires HE version 2.2.7 (May 2021) or newer!"
return null
}
}
def processWaterEvent( String value, boolean isDigital=false, boolean wasChecked=false ) {
def valueToBeSent = value
switch (value) {
case 'checking' :
valueToBeSent = 'checking'
state.isTesting = true
break
case 'tested' :
valueToBeSent = 'tested'
state.isTesting = true
runIn( 2, setDryDelayed, [overwrite: true])
break
case 'wet' :
if (settings?.testingDelay == false ) {
// send 'wet' without delays and additional checks
valueToBeSent = "wet"
state.isTesting = false
}
else {
// check whether this is the initial 'wet' event, or a confirmation after few seconds?
if (wasChecked == true) {
// scheduled call, confirmed from "checkIfStillWet()"
valueToBeSent = "wet"
}
else {
// this is the inital 'wet' event - to be verified!
valueToBeSent = "checking"
state.isTesting = true
runIn( settings?.minimumWetTime ?: 1, "checkIfStillWet")
}
}
if (isDigital==false ) {
state.lastWaterWet = FormattedDateTimeFromUnix( now() )
logDebug "setting state.lastWaterWet to ${state.lastWaterWet}"
}
break
case 'dry' :
// 'dry' may come after a test or after a real alarm
valueToBeSent = "dry"
descriptionText = "${device.displayName} is ${valueToBeSent}"
state.isTesting = false
break
case 'unknown' :
// 'unknown' is sent when the water leak sensor healthStatus goes in offline state
valueToBeSent = "unknown"
descriptionText = "${device.displayName} status is ${valueToBeSent}"
state.isTesting = false
break
default :
log.warn "processWaterEvent: unprocessed water event '${value}'"
return
}
sendWaterEvent( valueToBeSent, isDigital )
}
// should be called from processWaterEvent() only!
def sendWaterEvent( String value, boolean isDigital=false) {
def type = isDigital == true ? "digital" : "physical"
String descriptionText
switch (value) {
case 'checking' :
descriptionText = "${device.displayName} checking"
break
case 'tested' :
descriptionText = "${device.displayName} is tested"
break
case 'wet' :
if (settings?.testingDelay == false ) {
// send 'wet' without delays and additional checks
descriptionText = "${device.displayName} is wet"
}
else {
// check whether this is the initial 'wet' event, or a confirmation after few seconds?
if (wasChecked == true) {
// scheduled call, confirmed from "checkIfStillWet()"
descriptionText = "${device.displayName} is wet (checked)"
}
else {
// this is the inital 'wet' event - to be verified!
descriptionText = "${device.displayName} received wet status - checking again in ${settings?.minimumWetTime ?: 1} seconds"
}
}
break
case 'dry' :
// 'dry' may come after a test or after a real alarm
if (state.isTesting == true) {
descriptionText = "${device.displayName} is dry (test finished)"
}
else {
descriptionText = "${device.displayName} is dry"
}
break
case 'unknown' :
// 'unknown' is sent when the water leak sensor healthStatus goes in offline state
descriptionText = "${device.displayName} status is unknown"
break
default :
log.warn "sendWaterEvent: unprocessed water event '${value}'"
return
}
if (isDigital == true) descriptionText += " (digital)"
if (settings?.txtEnable==true) log.info "$descriptionText" // includes deviceName
sendEvent(name: "water", value: value, descriptionText: descriptionText, type: type , isStateChange: true)
}
def setDryDelayed() {
processWaterEvent( 'dry', isDigital=true, wasChecked=true )
}
def checkIfStillWet() {
// called when the first 'wet' event is received
if (device.currentValue('water', true) == 'checking') {
logDebug "sensor still reprots 'checking' after ${settings?.minimumWetTime ?: 1} seconds - this is a real alarm!"
state.isTesting = false
processWaterEvent( 'wet', isDigital=false, wasChecked=true )
}
else {
// the leak sensor status is back to 'tested' or 'dry', before the check timer expired - it was a test!
logDebug "it was a test (${device.currentValue('water', true)})"
state.isTesting = true
processWaterEvent( 'tested', isDigital=false, wasChecked=true )
}
unschedule("checkIfStillWet")
}
def wet() {
processWaterEvent( "wet", isDigital=true )
}
def dry() {
processWaterEvent( "dry", isDigital=true )
}
def push() {
processWaterEvent( "tested", isDigital=true )
}
def processTuyaCluster( descMap ) {
if (descMap?.clusterInt==CLUSTER_TUYA && descMap?.command == "24") { //getSETTIME
// Tuya time sync request is sent by NEO Coolcam sensors every 1 hour
logDebug "${device.displayName} Tuya time synchronization request"
def offset = 0
try {
offset = location.getTimeZone().getOffset(new Date().getTime())
}
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(CLUSTER_TUYA, SETTIME, "0008" +zigbee.convertToHexString((int)(now()/1000),8) + zigbee.convertToHexString((int)((now()+offset)/1000), 8))
logDebug "time now is: ${FormattedDateTimeFromUnix( now() )}"
logDebug "sending time data : ${cmds}"
cmds.each{ sendHubCommand(new hubitat.device.HubAction(it, hubitat.device.Protocol.ZIGBEE)) }
if (state.txCounter != null) state.txCounter = state.txCounter + 1
getBatteryPercentageResult((device.currentState('battery').value as int)* 2, isDigital=true) // added 04/06/2022 : send latest known battery level event to update the 'Last Activity At' timestamp
}
else if (descMap?.clusterInt==CLUSTER_TUYA && descMap?.command == "0B") { // ZCL Command Default Response
String clusterCmd = descMap?.data[0]
def status = descMap?.data[1]
logDebug "device has received Tuya cluster ZCL command 0x${clusterCmd} response 0x${status} data = ${descMap?.data}"
if (status != "00") {
logWarn "ATTENTION! manufacturer = ${device.getDataValue("manufacturer")} unsupported Tuya cluster ZCL command 0x${clusterCmd} response 0x${status} data = ${descMap?.data} !!!"
}
}
else if ((descMap?.clusterInt==CLUSTER_TUYA) && (descMap?.command == "01" || descMap?.command == "02"))
{
def transid = zigbee.convertHexToInt(descMap?.data[1])
def dp = zigbee.convertHexToInt(descMap?.data[2])
def dp_id = zigbee.convertHexToInt(descMap?.data[3])
def fncmd = getTuyaAttributeValue(descMap?.data) //
logDebug "Tuya Cluster command: dp_id=${dp_id} dp=${dp} fncmd=${fncmd}"
switch (dp) {
case 0x65 : // dry/wet
processWaterEvent( fncmd == 0 ? "dry" : "wet" )
break
case 0x66 : // battery
logDebug "Tuya battery status report dp_id=${dp_id} dp=${dp} fncmd=${fncmd}"
def rawValue = 0
if (fncmd == 0) rawValue = 100 // Battery Full
else if (fncmd == 1) rawValue = 75 // Battery High
else if (fncmd == 2) rawValue = 50 // Battery Medium
else if (fncmd == 3) rawValue = 25 // Battery Low
getBatteryPercentageResult(rawValue*2)
break
default :
logWarn "NOT PROCESSED TUYA COMMAND Tuya cmd: dp=${dp} value=${fncmd} descMap.data = ${descMap?.data}"
break
}
}
}
private int getTuyaAttributeValue(ArrayList _data) {
int retValue = 0
if (_data.size() >= 6) {
int dataLength = _data[5] as Integer
int power = 1;
for (i in dataLength..1) {
retValue = retValue + power * zigbee.convertHexToInt(_data[i+5])
power = power * 256
}
}
return retValue
}
// called on initial install of device during discovery
// also called from initialize() in this driver!
def installed() {
logInfo "${device.displayName} installed()... driver version ${driverVersionAndTimeStamp()}"
}
// called when preferences are saved
// runs when save is clicked in the preferences section
def updated() {
checkDriverVersion()
if (settings?.txtEnable == true) log.info "${device.displayName} Updating ${device.getLabel()} (${device.getName()}) model ${device.getDataValue('model')} manufacturer ${device.getDataValue('manufacturer')}"
if (settings?.txtEnable == true) log.info "${device.displayName} Debug logging is ${logEnable}; Description text logging is ${txtEnable}"
}
def refresh() {
logDebug "refresh()..."
zigbee.readAttribute(0, 1)
}
def powerSourceEvent( state = null) {
if (state != null && state == 'unknown' ) {
sendEvent(name : "powerSource", value : "unknown", descriptionText: "device is OFFLINE", type: "digital")
}
else {
sendEvent(name : "powerSource", value : "battery", descriptionText: "device is back online", type: "digital")
}
}
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?)'
}
// called when any event was received from the Zigbee device in parse() method..
def setPresent() {
powerSourceEvent()
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")
}
if (device.currentValue('water', true) != 'unknown') {
processWaterEvent( 'unknown', isDigital=true )
}
logWarn "is not present!"
}
}
else {
state.notPresentCounter = 1
}
}
// check for device offline every 60 minutes
def deviceHealthCheck() {
logDebug "deviceHealthCheck()..."
checkIfNotPresent()
runIn( DEFAULT_POLLING_INTERVAL, deviceHealthCheck, [overwrite: true])
}
def sendHealthStatusEvent(value) {
//log.trace "healthStatus ${value}"
def descriptionText = "healthStatus set to ${value}"
logInfo "${descriptionText}"
sendEvent(name: "healthStatus", value: value, descriptionText: descriptionText)
}
def pollPresence() {
deviceHealthCheck()
}
def configurePollPresence() {
runIn( DEFAULT_POLLING_INTERVAL, deviceHealthCheck, [overwrite: true, misfire: "ignore"])
}
def configureLogsOff() {
if (settings?.logEnable == true) {
runIn(86400, logsOff, [overwrite: true, misfire: "ignore"]) // turn off debug logging after 24 hours
logInfo "Debug logging will be turned off after 24 hours"
}
else {
unschedule(logsOff)
}
}
Integer safeToInt(val, Integer defaultVal=0) {
return "${val}"?.isInteger() ? "${val}".toInteger() : defaultVal
}
Double safeToDouble(val, Double defaultVal=0.0) {
return "${val}"?.isDouble() ? "${val}".toDouble() : defaultVal
}
def logDebug(msg) {
if (settings?.logEnable == null || settings?.logEnable == true) {
log.debug "${device.displayName} " + msg
}
}
def logInfo(msg) {
if (settings?.txtEnable == null || settings?.txtEnable == true) {
log.info "${device.displayName} " + msg
}
}
def logWarn(msg) {
if (settings?.logEnable == null || settings?.logEnable == true) {
log.warn "${device.displayName} " + msg
}
}
@Field static final String dateFormat = 'yyyy-MM-dd HH:mm:ss.SSS'
def unixFromFormattedDateTime( formattedDateTime ) {
def unixDateTime = Date.parse(dateFormat, formattedDateTime).time
return unixDateTime
}
def FormattedDateTimeFromUnix( unixDateTime ) {
def formattedDateTime = new Date(unixDateTime).format(dateFormat, location.timeZone)
return formattedDateTime
}
def driverVersionAndTimeStamp() {version()+' '+timeStamp()}
def checkDriverVersion() {
if (state.driverVersion == null || driverVersionAndTimeStamp() != state.driverVersion) {
logInfo "updating the settings from the current driver version ${state.driverVersion} to the new version ${driverVersionAndTimeStamp()}"
initializeVars( fullInit = false )
configurePollPresence()
configureLogsOff()
state.driverVersion = driverVersionAndTimeStamp()
}
}
def logInitializeRezults() {
logInfo "manufacturer = ${device.getDataValue("manufacturer")}"
logInfo "Initialization finished\r version=${version()} (Timestamp: ${timeStamp()})"
}
// called by configure(fullInit) button
void initializeVars( boolean fullInit = true ) {
logDebug "InitializeVars()... fullInit = ${fullInit}"
if (fullInit == true) {
state.clear()
state.driverVersion = driverVersionAndTimeStamp()
}
if (fullInit == true || state.packetID == null) state.notPresentCounter = 0
if (fullInit == true || state.rxCounter == null) state.rxCounter = 0
if (fullInit == true || state.txCounter == null) state.txCounter = 0
if (fullInit == true || state.rejoinCounter == null) state.rejoinCounter = 0
if (fullInit == true || state.isTesting == null) state.isTesting = false
if (state.lastBattery == null) state.lastBattery = "0"
if (state.lastWaterWet == null) state.lastWaterWet = "unknown" //FormattedDateTimeFromUnix( now() )
if (fullInit == true || state.notPresentCounter == null) state.notPresentCounter = 0
if (device.currentValue('powerSource', true) == null) sendEvent(name : "powerSource", descriptionText: "device just installed", value : "?", isStateChange : true)
if (device.currentValue('water', true) == null) sendEvent(name : "water", value : "unknown", descriptionText: "device just installed", isStateChange : true)
if (device.currentValue('healthStatus', true) == null) sendEvent(name: "healthStatus", value: "unknown", descriptionText: "device just installed", type: 'digital' , isStateChange: true )
if (fullInit == true || settings?.logEnable == null) device.updateSetting("logEnable", [value:debugLogsDefault, type:"bool"])
if (fullInit == true || settings?.txtEnable == null) device.updateSetting("txtEnable", [value: true, type:"bool"])
if (fullInit == true || settings?.testingDelay == null) device.updateSetting("testingDelay", [value: false, type:"bool"])
if (fullInit == true || settings?.minimumWetTime == null) device.updateSetting("minimumWetTime", [value:3, type:"number"])
}
def tuyaBlackMagic() {
List cmds = []
cmds += zigbee.readAttribute(0x0000, [0x0004, 0x000, 0x0001, 0x0005, 0x0007, 0xfffe], [:], delay=200)
cmds += zigbee.writeAttribute(0x0000, 0xffde, 0x20, 0x13, [:], delay=200)
return cmds
}
// called when used with capability "Configuration" is called when the configure button is pressed on the device page.
// Runs when driver is installed, after installed() is run. if capability Configuration exists, a Configure command is added to the ui
// It is also called on initial install after discovery.
def configure() {
List cmds = []
logInfo "configure().."
unschedule()
initializeVars(fullInit = true)
configurePollPresence()
configureLogsOff()
cmds += tuyaBlackMagic()
cmds += zigbee.enrollResponse() + zigbee.readAttribute(0x0500, 0x0000)
sendZigbeeCommands(cmds)
}
// called when used with capability "Initialize" it will call this method every time the hub boots up. So for things that need refreshing or re-connecting (LAN integrations come to mind here) ..
// runs first time driver loads, ie system startup
// when capability Initialize exists, a Initialize command is added to the ui.
def initialize() {
logInfo "Initialize()..."
installed()
updated()
configure()
runIn( 3, logInitializeRezults)
}
private sendTuyaCommand(dp, dp_type, fncmd) {
ArrayList cmds = []
cmds += zigbee.command(CLUSTER_TUYA, SETDATA, PACKET_ID + dp + dp_type + zigbee.convertToHexString((int)(fncmd.length()/2), 4) + fncmd )
logDebug "sendTuyaCommand = ${cmds}"
state.txCounter = (state.txCounter ?: 0) + 1
return cmds
}
void sendZigbeeCommands(ArrayList cmd) {
logDebug "sendZigbeeCommands (cmd=${cmd})"
hubitat.device.HubMultiAction allActions = new hubitat.device.HubMultiAction()
cmd.each {
allActions.add(new hubitat.device.HubAction(it, hubitat.device.Protocol.ZIGBEE))
state.txCounter = (state.txCounter ?: 0) + 1
}
sendHubCommand(allActions)
}
private getPACKET_ID() {
state.packetID = ((state.packetID ?: 0) + 1 ) % 65536
return zigbee.convertToHexString(state.packetID, 4)
}
private getDescriptionText(msg) {
def descriptionText = "${device.displayName} ${msg}"
logInfo "${descriptionText}"
return descriptionText
}
def logsOff(){
log.info "${device.displayName} debug logging disabled..."
device.updateSetting("logEnable",[value: false, type:"bool"])
}
def getBatteryPercentageResult(rawValue, isDigital=false) {
logDebug "Battery Percentage rawValue = ${rawValue} -> ${rawValue / 2}%"
def result = [:]
if (0 <= rawValue && rawValue <= 200) {
result.name = 'battery'
result.translatable = true
result.value = Math.round(rawValue / 2)
result.isStateChange = true
result.unit = '%'
result.type = isDigital == true ? "digital" : "physical"
result.descriptionText = "${device.displayName} battery is ${result.value}% ($result.type)"
state.lastBattery = (result.value).toString()
sendEvent(result)
logInfo "${result.descriptionText}, water:${device.currentState('water').value}"
}
else {
logWarn "${device.displayName} ignoring BatteryPercentageResult(${rawValue})"
}
}
private Map getBatteryResult(rawValue) {
logDebug "${device.displayName} batteryVoltage = ${(double)rawValue / 10.0} V"
def result = [:]
def volts = rawValue / 10
if (!(rawValue == 0 || rawValue == 255)) {
def minVolts = 2.1
def maxVolts = 3.0
def pct = (volts - minVolts) / (maxVolts - minVolts)
def roundedPct = Math.round(pct * 100)
if (roundedPct <= 0)
roundedPct = 1
result.value = Math.min(100, roundedPct)
result.descriptionText = "${device.displayName} battery is ${result.value}% (${volts} V)"
result.name = 'battery'
result.unit = '%'
result.type = "physical"
result.isStateChange = true
logInfo "${result.descriptionText}, water:${device.currentState('water').value}"
state.lastBattery = roundedPct.toString()
sendEvent(result)
}
else {
logWarn "${device.displayName} ignoring BatteryResult(${rawValue})"
}
}
def sendBatteryEvent( roundedPct, isDigital=false ) {
sendEvent(name: 'battery', value: roundedPct, unit: "%", type: isDigital == true ? "digital" : "physical", isStateChange: true )
}
def test () {
List cmds = []
cmds += "zdo bind 0x${device.deviceNetworkId} 0x01 0x01 0x0001 {${device.zigbeeId}} {}"
cmds += zigbee.readAttribute(0x0001, 0x0020, [:], delay=200) // battery voltage
cmds += zigbee.readAttribute(0x0001, 0x0021, [:], delay=200) // battery percentage
if (cmds != null && cmds != [] ) {
sendZigbeeCommands(cmds)
}
}