Hi! I couldn't find a good driver from the community and the "generic zigbee leak detector" didn't want to work with the HomeKit Integration, and I definitely don't have the skills to write one, so I did what I thought might be a good idea and I asked ChatGPT.
After a few tries to weed out some errors, we got one that works perfectly and supports every funtion- including Homekit.
here it is for your enjoyment!
/*
- Third Reality 3RWS18BZ Water Leak Sensor
- Custom Hubitat Elevation Driver
- Version: 1.2.0
- Intended for:
- Third Reality 3RWS18BZ
- Hubitat Elevation C-7 / C-8 / C-8 Pro
- Features:
-
- Wet / dry detection
-
- Battery percentage
-
- Battery voltage
-
- Hourly duplicate wet/dry reports preserved for activity tracking
-
- Last check-in
-
- Online / offline health status
-
- Built-in buzzer ON/OFF
-
- 5-second buzzer test
-
- Refresh
-
- Configure
*/
- Configure
import hubitat.zigbee.clusters.iaszone.ZoneStatus
import hubitat.zigbee.zcl.DataType
metadata {
definition(
name: "Third Reality 3RWS18BZ Water Leak Sensor",
namespace: "rjpeters",
author: "Rob / ChatGPT"
) {
capability "WaterSensor"
capability "Battery"
capability "Sensor"
capability "Refresh"
capability "Configuration"
capability "Initialize"
attribute "batteryVoltage", "number"
attribute "lastCheckin", "string"
attribute "healthStatus", "enum", ["online", "offline"]
command "buzzerOn"
command "buzzerOff"
command "testBuzzer"
fingerprint(
profileId: "0104",
endpointId: "01",
inClusters: "0000,0001,0500",
outClusters: "0019,0006",
model: "3RWS18BZ",
manufacturer: "Third Reality, Inc"
)
}
preferences {
input name: "infoLogging",
type: "bool",
title: "Enable description text logging",
description: "Logs wet/dry and other normal sensor events",
defaultValue: true,
required: true
input name: "debugLogging",
type: "bool",
title: "Enable debug logging",
description: "Useful while testing the driver; automatically turns off after 30 minutes",
defaultValue: false,
required: true
input name: "offlineHours",
type: "enum",
title: "Device offline timeout",
description: "Mark the sensor offline if no Zigbee messages are received for this long",
options: [
"2" : "2 hours",
"4" : "4 hours",
"8" : "8 hours",
"12" : "12 hours",
"24" : "24 hours",
"26" : "26 hours",
"48" : "48 hours"
],
defaultValue: "26",
required: true
}
}
/*
- ============================================================
- INSTALL / UPDATE / INITIALIZE
- ============================================================
*/
def installed() {
log.info "${device.displayName}: Third Reality 3RWS18BZ driver installed"
initialize()
runIn(2, "configure")
}
def updated() {
log.info "${device.displayName}: driver preferences saved"
unschedule()
initialize()
if (debugLogging == true) {
log.warn "${device.displayName}: debug logging will automatically turn off in 30 minutes"
runIn(1800, "debugLoggingOff")
}
}
def initialize() {
if (device.currentValue("water") == null) {
sendEvent(
name: "water",
value: "dry"
)
}
if (device.currentValue("healthStatus") == null) {
sendEvent(
name: "healthStatus",
value: "online"
)
}
runEvery1Hour("healthCheck")
}
/*
- ============================================================
- CONFIGURE
- ============================================================
*/
def configure() {
logInfo("${device.displayName}: configuring Third Reality 3RWS18BZ")
state.lastCheckin = now()
sendEvent(
name: "healthStatus",
value: "online"
)
List<String> cmds = []
/*
* IAS enrollment
*/
try {
cmds += zigbee.enrollResponse()
}
catch (Exception e) {
logDebug("IAS enrollment response error: ${e.message}")
}
/*
* Configure IAS Zone Status reporting.
*
* The 3RWS18BZ is known to send an unchanged DRY report
* approximately hourly on newer firmware. We preserve those
* duplicate reports because they are useful as a heartbeat.
*/
try {
cmds += zigbee.configureReporting(
0x0500,
0x0002,
DataType.BITMAP16,
30,
3600,
null
)
}
catch (Exception e) {
logDebug("IAS reporting configuration error: ${e.message}")
}
/*
* Ask for battery voltage periodically.
*
* Battery voltage:
* Cluster 0x0001
* Attribute 0x0020
*/
try {
cmds += zigbee.configureReporting(
0x0001,
0x0020,
DataType.UINT8,
3600,
43200,
1
)
}
catch (Exception e) {
logDebug("Battery reporting configuration error: ${e.message}")
}
/*
* Read the current state.
*/
cmds += buildRefreshCommands()
return cmds
}
/*
- ============================================================
- REFRESH
- ============================================================
*/
def refresh() {
logDebug("Manual refresh requested")
return buildRefreshCommands()
}
List buildRefreshCommands() {
List<String> cmds = []
/*
* IAS Zone Status
*/
cmds += zigbee.readAttribute(
0x0500,
0x0002
)
/*
* Battery Voltage
*/
cmds += zigbee.readAttribute(
0x0001,
0x0020
)
/*
* Battery Percentage Remaining
*
* Some firmware revisions support this directly,
* some do not.
*/
cmds += zigbee.readAttribute(
0x0001,
0x0021
)
return cmds
}
/*
- ============================================================
- BUZZER
- Third Reality exposes Zigbee On/Off cluster 0x0006
- as an OUT cluster.
- Generic Zigbee Switch ON/OFF has been confirmed to
- turn the 3RWS18BZ buzzer on and off.
- ============================================================
*/
def buzzerOn() {
logInfo("${device.displayName}: buzzer ON")
return zigbee.command(
0x0006,
0x01
)
}
def buzzerOff() {
logInfo("${device.displayName}: buzzer OFF")
return zigbee.command(
0x0006,
0x00
)
}
def testBuzzer() {
logInfo("${device.displayName}: starting 5-second buzzer test")
runIn(
5,
"buzzerOff"
)
return zigbee.command(
0x0006,
0x01
)
}
/*
- ============================================================
- ZIGBEE PARSER
- ============================================================
*/
def parse(String description) {
logDebug("RX: ${description}")
markDeviceOnline()
/*
* IAS enrollment request
*/
if (description?.startsWith("enroll request")) {
logDebug("IAS enrollment request received")
return zigbee.enrollResponse()
}
/*
* Hubitat-formatted IAS Zone Status
*/
if (description?.startsWith("zone status")) {
try {
ZoneStatus zoneStatus =
zigbee.parseZoneStatus(description)
processZoneStatus(zoneStatus)
}
catch (Exception e) {
log.warn "${device.displayName}: unable to parse IAS Zone Status: ${e.message}"
}
return
}
/*
* Normal Zigbee description map
*/
Map descMap
try {
descMap =
zigbee.parseDescriptionAsMap(
description
)
}
catch (Exception e) {
log.warn "${device.displayName}: Zigbee parse error: ${e.message}"
return
}
if (!descMap) {
return
}
logDebug("MAP: ${descMap}")
/*
* Hubitat may expose the cluster as cluster
* or clusterId depending on the message type.
*/
String cluster =
descMap.cluster ?: descMap.clusterId
/*
* ========================================================
* IAS ZONE
* ========================================================
*/
if (cluster == "0500") {
parseIASMap(descMap)
return
}
/*
* ========================================================
* POWER CONFIGURATION
* ========================================================
*/
if (cluster == "0001") {
parseBatteryMap(descMap)
return
}
/*
* ========================================================
* ON/OFF RESPONSE
* ========================================================
*/
if (cluster == "0006") {
logDebug("On/Off cluster response: ${descMap}")
return
}
/*
* Ask Hubitat whether it recognizes another standard event.
*/
try {
def event =
zigbee.getEvent(description)
if (event) {
logDebug("Standard Zigbee event: ${event}")
sendEvent(event)
return
}
}
catch (Exception ignored) {
}
logDebug("Unhandled Zigbee message: ${descMap}")
}
/*
- ============================================================
- IAS PARSING
- ============================================================
*/
void parseIASMap(Map descMap) {
/*
* IAS Zone Status attribute
*
* Cluster: 0x0500
* Attribute: 0x0002
*/
if (
descMap.attrId == "0002" &&
descMap.value != null
) {
try {
Integer status =
Integer.parseInt(
descMap.value,
16
)
processIASBitmap(status)
}
catch (Exception e) {
log.warn "${device.displayName}: unable to decode IAS value ${descMap.value}"
}
return
}
/*
* IAS Zone Status Change Notification
*/
if (
descMap.command == "00" &&
descMap.data instanceof List &&
descMap.data.size() >= 2
) {
try {
Integer lowByte =
Integer.parseInt(
descMap.data[0],
16
)
Integer highByte =
Integer.parseInt(
descMap.data[1],
16
)
Integer status =
lowByte |
(highByte << 8)
processIASBitmap(status)
}
catch (Exception e) {
logDebug("Unable to parse IAS notification: ${e.message}")
}
return
}
logDebug("Unhandled IAS message: ${descMap}")
}
void processZoneStatus(ZoneStatus status) {
boolean wet =
status.isAlarm1Set()
logDebug(
"IAS ZoneStatus Alarm1=${wet}"
)
processWater(
wet
)
}
void processIASBitmap(Integer status) {
/*
* IAS Zone Status
*
* Bit 0 = Alarm 1
*
* For this leak sensor:
* Alarm 1 = WATER
*/
boolean wet =
(status & 0x0001) != 0
logDebug(
"IAS bitmap 0x${String.format('%04X', status)} water=${wet ? 'wet' : 'dry'}"
)
processWater(
wet
)
}
/*
- ============================================================
- WATER EVENTS
- ============================================================
*/
void processWater(boolean wet) {
String value =
wet ?
"wet" :
"dry"
String previous =
device.currentValue(
"water"
)
/*
* IMPORTANT:
*
* We deliberately send the event even if water remains DRY.
*
* Newer 3RWS18BZ firmware has been observed sending a DRY
* status approximately every hour. Allowing that event
* through gives Hubitat a useful activity heartbeat.
*/
boolean changed =
previous != value
sendEvent(
name: "water",
value: value,
isStateChange: changed,
descriptionText:
"${device.displayName} is ${value}"
)
if (changed) {
if (wet) {
log.warn "${device.displayName}: WATER DETECTED"
}
else {
logInfo(
"${device.displayName}: water sensor is dry"
)
}
}
else {
logDebug(
"Water remains ${value}; heartbeat event received"
)
}
}
/*
- ============================================================
- BATTERY
- ============================================================
*/
void parseBatteryMap(Map descMap) {
if (descMap.value == null) {
return
}
/*
* Battery Voltage
*
* Attribute 0x0020
*
* Zigbee reports in 100 mV units:
*
* 32 = 3.2 V
* 30 = 3.0 V
* 25 = 2.5 V
*/
if (descMap.attrId == "0020") {
try {
Integer raw =
Integer.parseInt(
descMap.value,
16
)
/*
* 0 and 255 are invalid/unknown according to ZCL.
*/
if (
raw == 0 ||
raw == 255
) {
return
}
BigDecimal volts =
raw / 10.0
sendEvent(
name: "batteryVoltage",
value: volts,
unit: "V"
)
/*
* Approximate percentage from voltage.
*
* The 3RWS18BZ uses two AAA cells.
*
* Approximation:
* 3.2 V = 100%
* 2.1 V = 1%
*/
BigDecimal minimum =
2.1
BigDecimal maximum =
3.2
BigDecimal calculated =
(
(volts - minimum) /
(maximum - minimum)
) * 100
Integer percent =
Math.round(
calculated
) as Integer
percent =
Math.max(
1,
Math.min(
100,
percent
)
)
sendEvent(
name: "battery",
value: percent,
unit: "%"
)
logDebug(
"Battery voltage=${volts}V calculated=${percent}%"
)
}
catch (Exception e) {
log.warn "${device.displayName}: battery voltage parse error: ${e.message}"
}
return
}
/*
* Battery Percentage Remaining
*
* Attribute 0x0021
*
* Zigbee represents battery percentage in half-percent units.
*
* Raw C8 hex = 200 decimal = 100%
*/
if (descMap.attrId == "0021") {
try {
Integer raw =
Integer.parseInt(
descMap.value,
16
)
if (raw == 255) {
return
}
Integer percent =
Math.round(
raw / 2.0
) as Integer
percent =
Math.max(
0,
Math.min(
100,
percent
)
)
sendEvent(
name: "battery",
value: percent,
unit: "%"
)
logDebug(
"Battery percentage=${percent}% raw=${raw}"
)
}
catch (Exception e) {
log.warn "${device.displayName}: battery percentage parse error: ${e.message}"
}
return
}
logDebug(
"Unhandled Power Configuration attribute: ${descMap}"
)
}
/*
- ============================================================
- DEVICE HEALTH
- ============================================================
*/
void markDeviceOnline() {
Long timestamp =
now()
state.lastCheckin =
timestamp
String formatted =
new Date(
timestamp
).format(
"yyyy-MM-dd HH:mm:ss",
location.timeZone
)
sendEvent(
name: "lastCheckin",
value: formatted
)
if (
device.currentValue("healthStatus") !=
"online"
) {
sendEvent(
name: "healthStatus",
value: "online",
descriptionText:
"${device.displayName} is online"
)
logInfo(
"${device.displayName}: back online"
)
}
}
def healthCheck() {
if (!state.lastCheckin) {
logDebug(
"Health check skipped; no Zigbee messages received yet"
)
return
}
Integer hours =
offlineHours ?
offlineHours.toInteger() :
26
Long maximumAge =
hours *
60L *
60L *
1000L
Long elapsed =
now() -
(state.lastCheckin as Long)
if (
elapsed >
maximumAge
) {
if (
device.currentValue("healthStatus") !=
"offline"
) {
sendEvent(
name: "healthStatus",
value: "offline",
descriptionText:
"${device.displayName} has not reported for ${hours} hours"
)
log.warn "${device.displayName}: OFFLINE - no Zigbee messages for more than ${hours} hours"
}
}
else {
if (
device.currentValue("healthStatus") !=
"online"
) {
sendEvent(
name: "healthStatus",
value: "online"
)
}
}
}
/*
- ============================================================
- LOGGING
- ============================================================
*/
void logInfo(String message) {
if (
infoLogging != false
) {
log.info message
}
}
void logDebug(String message) {
if (
debugLogging == true
) {
log.debug "${device.displayName}: ${message}"
}
}
def debugLoggingOff() {
log.warn "${device.displayName}: debug logging automatically disabled"
device.updateSetting(
"debugLogging",
[
value: "false",
type: "bool"
]
)
}