Thirdreality 3RWS18BZ Water Leak Detector Driver!

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
      */

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"
    ]
)

}

1 Like