Third Reality Vibration Sensor (Fan Monitor) Groovy driver for 3RVS01031Z Zigbee vibration sensor

Attached is a Groovy driver for Hubitat, for use as an exhaust fan ON/OFF monitor using a ThirdReality 3RVS01031Z Zigbee vibration sensor.

My use for the sensor is to attach it to an exhaust fan in my attic to monitor when the fan goes ON or OFF (the fan is temp and humidity controlled by a "dumb" control box). I wanted the driver to assert some signal when the fan is ON (moving), and de-assert that signal when the fan is OFF -- essentially a binary signal. So I had Claude AI modify @kkossev's excellent more general "Tuya Zigbee Vibration Sensor" driver to be specific to my purpose.

This driver turns the sensor's vibration reports into a steady running/stopped signal for a machine that vibrates continuously, such as an exhaust fan:

* acceleration = active -> fan running

* acceleration = inactive -> fan stopped

All other code not needed for this specific sensor has been removed.

Reported info:

/**
 *  Third Reality Vibration Sensor (Fan Monitor)
 *  Device driver for Hubitat Elevation - ThirdReality 3RVS01031Z only
 *
 *  Turns the sensor's vibration reports into a steady running/stopped signal for a machine that
 *  vibrates continuously, such as an exhaust fan:
 *      acceleration = active    -> fan running
 *      acceleration = inactive  -> fan stopped
 *
 *  Derived from 'Tuya ZigBee Vibration Sensor' 1.4.7 by Krassimir Kossev (kkossev),
 *  https://github.com/kkossev/Hubitat, which is based on work by Mikhail Diatchenko (muxa),
 *  Robert Morris and ssalahi.
 *  Modifications: reduced to the 3RVS01031Z; vibration on/off logic reworked so that inactive is
 *  reported only after a configurable quiet period; Tuya, Samsung and garage-door code removed.
 *
 *  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.
 */

import groovy.json.JsonOutput
import groovy.transform.Field
import hubitat.zigbee.clusters.iaszone.ZoneStatus

static String version() { '2.1.1' }

@Field static final Integer DEFAULT_OFF_DELAY_SECS = 120
@Field static final Integer DEFAULT_QUIET_TIMEOUT_MINS = 0
@Field static final Integer DEFAULT_OFFLINE_HOURS = 49     // the sensor reports battery about once a day on its own
@Field static final BigDecimal MIN_BATTERY_VOLTS = 2.5
@Field static final BigDecimal MAX_BATTERY_VOLTS = 3.0
@Field static final int TR_ACCEL_CLUSTER = 0xFFF1     // Third Reality private cluster: 0x0001..0x0003 = x, y, z

metadata {
    definition(name: 'Third Reality Vibration Sensor (Fan Monitor)', namespace: 'custom', author: 'Krassimir Kossev (original); modified', singleThreaded: true) {
        capability 'Sensor'
        capability 'AccelerationSensor'     // acceleration: active = running, inactive = stopped
        capability 'Battery'
        capability 'Configuration'
        capability 'Refresh'
        capability 'ThreeAxis'

        command 'setInactive', [[name: 'Force acceleration to inactive now']]

        attribute 'batteryVoltage', 'number'
        attribute 'batteryStatus', 'enum', ['normal', 'replace']
        attribute 'healthStatus', 'enum', ['unknown', 'offline', 'online']
        attribute 'lastStarted', 'string'       // when acceleration last went active (local time)
        attribute 'lastStopped', 'string'       // when vibration actually stopped (the sensor's clear, not the delayed inactive event)
        attribute 'lastRunMinutes', 'number'    // length of the last completed run

        fingerprint profileId: '0104', endpointId: '01', inClusters: '0000,0001,0500,FFF1', outClusters: '0019', model: '3RVS01031Z', manufacturer: 'Third Reality, Inc'
    }

    preferences {
        input name: 'offDelay', type: 'number', title: '<b>Off delay</b> (seconds)',
              description: 'After the sensor reports that vibration stopped, wait this long before reporting inactive. Any new vibration report cancels the wait. Set it longer than the longest gap between vibration reports while the fan runs. 0 = report inactive immediately.',
              range: '0..3600', defaultValue: DEFAULT_OFF_DELAY_SECS
        input name: 'quietTimeout', type: 'number', title: '<b>Quiet timeout</b> (minutes)',
              description: 'Only for a sensor that never reports that vibration stopped: report inactive after this many minutes without a vibration report. 0 = disabled (recommended unless the debug log shows no "vibration cleared" reports).',
              range: '0..1440', defaultValue: DEFAULT_QUIET_TIMEOUT_MINS
        input name: 'threeAxisEnable', type: 'bool', title: '<b>Three-axis events</b>',
              description: 'Send threeAxis events from the accelerometer reports. Not needed for on/off monitoring.', defaultValue: false
        input name: 'offlineHours', type: 'number', title: '<b>Offline after</b> (hours)',
              description: 'Set healthStatus to offline after this many hours with no message from the sensor. The sensor sends battery reports about once a day on its own, so keep this above 24. 0 = disabled.',
              range: '0..168', defaultValue: DEFAULT_OFFLINE_HOURS
        input name: 'txtEnable', type: 'bool', title: '<b>Enable descriptionText logging</b>', defaultValue: true
        input name: 'logEnable', type: 'bool', title: '<b>Enable debug logging</b>', description: 'Turns itself off after 24 hours.', defaultValue: false
    }
}

// ---------------------------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------------------------

void installed() {
    logInfo "installed, driver version ${version()}"
    sendEvent(name: 'acceleration', value: 'inactive', descriptionText: 'initialized to inactive')
    sendEvent(name: 'healthStatus', value: 'unknown')
    scheduleHealthCheck()
}

void updated() {
    logInfo "preferences saved, driver version ${version()}"
    unschedule('logsOff')
    if (settings?.logEnable) { runIn(86400, 'logsOff') }
    scheduleHealthCheck()
    if (!settings?.threeAxisEnable && device.currentValue('threeAxis') != null) {
        device.deleteCurrentState('threeAxis')
    }
    // re-arm any pending inactive timer with the new values
    if (device.currentValue('acceleration') == 'active') {
        if (state.clearedAt != null) {
            runIn(offDelaySecs(), 'offDelayExpired')
        }
        else if (quietTimeoutMins() > 0) {
            runIn(quietTimeoutMins() * 60, 'quietTimeoutExpired')
        }
        else {
            unschedule('quietTimeoutExpired')
        }
    }
    configure()
}

void configure() {
    logInfo 'configuring IAS enrollment (the sensor accepts this only while awake)'
    // Battery reporting is not configured: the 3RVS01031Z rejects it (status 0x86) and reports battery on its own schedule
    List<String> cmds = []
    cmds += zigbee.enrollResponse(200)
    cmds += refreshCmds()
    sendZigbeeCommands(cmds)
}

void refresh() {
    logInfo 'refresh requested (a sleeping sensor answers only when it next wakes up)'
    sendZigbeeCommands(refreshCmds())
}

private List<String> refreshCmds() {
    List<String> cmds = []
    cmds += zigbee.readAttribute(0x0001, 0x0020, [:], 200)   // battery voltage
    cmds += zigbee.readAttribute(0x0001, 0x0021, [:], 200)   // battery percentage
    cmds += zigbee.readAttribute(0x0500, 0x0002, [:], 200)   // IAS zone status
    return cmds
}

void logsOff() {
    log.warn "${device.displayName}: debug logging disabled"
    device.updateSetting('logEnable', [value: 'false', type: 'bool'])
}

// ---------------------------------------------------------------------------------------------
// Parsing
// ---------------------------------------------------------------------------------------------

void parse(String description) {
    if (!description) { return }
    logDebug "parse: ${description}"
    markOnline()

    if (description.startsWith('zone status')) {
        handleZoneStatus(zigbee.parseZoneStatus(description))
        return
    }
    if (description.startsWith('enroll request')) {
        logDebug 'IAS enroll request, sending enroll response'
        runIn(1, 'sendEnrollResponse')
        return
    }
    if (!(description.startsWith('catchall') || description.startsWith('read attr'))) {
        logDebug "unhandled description: ${description}"
        return
    }

    Map descMap = zigbee.parseDescriptionAsMap(description)
    if (descMap.profileId == '0000') { return }      // ZDO housekeeping

    List<Map> attrs = collectAttributes(descMap)
    if (attrs.isEmpty()) {
        handleGeneralCommand(descMap)
        return
    }
    switch (descMap.clusterInt) {
        case 0x0001:
            attrs.each { handleBatteryAttribute(it) }
            break
        case 0x0500:
            attrs.each { handleIasAttribute(it) }
            break
        case TR_ACCEL_CLUSTER:
            handleThreeAxis(attrs)
            break
        default:
            logDebug "unhandled attribute report: ${descMap}"
            break
    }
}

// Flattens the primary attribute and any additionalAttrs of a read response or report into one list
private List<Map> collectAttributes(Map descMap) {
    List<Map> attrs = []
    if (descMap.attrInt != null) {
        attrs << [attrInt: descMap.attrInt as Integer, value: descMap.value]
    }
    descMap.additionalAttrs?.each { Map a ->
        Integer id = a.attrInt != null ? a.attrInt as Integer : (a.attrId ? zigbee.convertHexToInt(a.attrId as String) : null)
        if (id != null) { attrs << [attrInt: id, value: a.value] }
    }
    return attrs
}

// Acks and responses that carry no attribute values
private void handleGeneralCommand(Map descMap) {
    List data = descMap.data ?: []
    switch (descMap.command) {
        case '07':  // Configure Reporting Response
            if (data && data[0] == '00') { logDebug "cluster 0x${descMap.clusterId}: reporting configured" }
            else { logWarn "cluster 0x${descMap.clusterId}: reporting configuration failed (data=${data})" }
            break
        case '04':  // Write Attributes Response
            if (data && data[0] != '00') { logWarn "cluster 0x${descMap.clusterId}: write failed, status 0x${data[0]}" }
            break
        case '0B':  // Default Response: [command, status]
            if (data.size() >= 2 && data[1] != '00') { logWarn "cluster 0x${descMap.clusterId}: command 0x${data[0]} returned status 0x${data[1]}" }
            break
        default:
            logDebug "unhandled message: ${descMap}"
            break
    }
}

// ---------------------------------------------------------------------------------------------
// Vibration -> running / stopped
// ---------------------------------------------------------------------------------------------

private void handleIasAttribute(Map attr) {
    if (attr.value == null) { return }
    switch (attr.attrInt) {
        case 0x0002:
            handleZoneStatus(new ZoneStatus(zigbee.convertHexToInt(attr.value as String)))
            break
        case 0x0000:
            logDebug "IAS zone state: ${attr.value == '01' ? 'enrolled' : 'not enrolled'} (${attr.value})"
            break
        default:
            logDebug "IAS attribute 0x${String.format('%04X', attr.attrInt)} = ${attr.value}"
            break
    }
}

private void handleZoneStatus(ZoneStatus zs) {
    boolean vibrating = zs.alarm1Set || zs.alarm2Set
    logDebug "zone status: ${vibrating ? 'vibration reported' : 'vibration cleared'} (alarm1=${zs.alarm1Set} alarm2=${zs.alarm2Set} batteryLow=${zs.batterySet})"
    sendBatteryStatus(zs.batterySet)
    if (vibrating) {
        vibrationReported()
    }
    else {
        vibrationCleared()
    }
}

private void vibrationReported() {
    unschedule('offDelayExpired')
    state.remove('clearedAt')
    if (quietTimeoutMins() > 0) {
        runIn(quietTimeoutMins() * 60, 'quietTimeoutExpired')
    }
    if (device.currentValue('acceleration') != 'active') {
        state.activeSince = now()
        sendEventLogged(name: 'acceleration', value: 'active', type: 'physical', descriptionText: 'vibration detected, acceleration active')
        sendEvent(name: 'lastStarted', value: formatTime(state.activeSince as Long))
    }
}

private void vibrationCleared() {
    unschedule('quietTimeoutExpired')
    if (device.currentValue('acceleration') != 'active') { return }
    if (state.clearedAt == null) { state.clearedAt = now() }
    int delay = offDelaySecs()
    if (delay == 0) {
        goInactive('physical')
    }
    else {
        logDebug "sensor cleared vibration; inactive in ${delay}s unless vibration resumes"
        runIn(delay, 'offDelayExpired')
    }
}

void offDelayExpired() {
    goInactive('physical')
}

void quietTimeoutExpired() {
    logDebug "no vibration report for ${quietTimeoutMins()} minutes"
    goInactive('digital')
}

void setInactive() {
    goInactive('digital')
}

private void goInactive(String type) {
    unschedule('offDelayExpired')
    unschedule('quietTimeoutExpired')
    Long started = state.activeSince as Long
    Long ended = (state.clearedAt ?: now()) as Long
    state.remove('activeSince')
    state.remove('clearedAt')
    if (device.currentValue('acceleration') == 'inactive') { return }
    String ran = started != null ? " after ${formatDuration(ended - started)} of vibration" : ''
    sendEventLogged(name: 'acceleration', value: 'inactive', type: type, descriptionText: "acceleration inactive${ran}")
    sendEvent(name: 'lastStopped', value: formatTime(ended))
    if (started != null) {
        sendEvent(name: 'lastRunMinutes', value: Math.round((ended - started) / 6000.0) / 10.0, unit: 'min')
    }
}

private String formatTime(long epochMs) {
    return new Date(epochMs).format('yyyy-MM-dd HH:mm:ss', location.timeZone)
}

private static String formatDuration(long ms) {
    long s = Math.max(0L, ms).intdiv(1000)
    if (s >= 3600) { return "${s.intdiv(3600)}h ${(s % 3600).intdiv(60)}m" }
    if (s >= 60) { return "${s.intdiv(60)}m ${s % 60}s" }
    return "${s}s"
}

// ---------------------------------------------------------------------------------------------
// Battery
// ---------------------------------------------------------------------------------------------

private void handleBatteryAttribute(Map attr) {
    if (attr.value == null) { return }
    int raw = zigbee.convertHexToInt(attr.value as String)
    if (raw == 0xFF) { return }      // invalid / unknown
    switch (attr.attrInt) {
        case 0x0020:    // BatteryVoltage, 100 mV units
            BigDecimal volts = raw / 10.0
            sendEventLogged(name: 'batteryVoltage', value: volts, unit: 'V', descriptionText: "battery voltage is ${volts} V")
            if (!state.batteryPercentReported) {    // fall back to an estimate only if the sensor never reports a percentage
                BigDecimal pct = (volts - MIN_BATTERY_VOLTS) * 100 / (MAX_BATTERY_VOLTS - MIN_BATTERY_VOLTS)
                sendBatteryPercent(Math.max(0, Math.min(100, pct as int)))
            }
            break
        case 0x0021:    // BatteryPercentageRemaining, 0.5 % units
            state.batteryPercentReported = true
            sendBatteryPercent(Math.min(100, raw.intdiv(2)))
            break
        default:
            logDebug "power configuration attribute 0x${String.format('%04X', attr.attrInt)} = ${attr.value}"
            break
    }
}

private void sendBatteryPercent(int pct) {
    sendEventLogged(name: 'battery', value: pct, unit: '%', descriptionText: "battery is ${pct}%")
}

private void sendBatteryStatus(boolean isLow) {
    String value = isLow ? 'replace' : 'normal'
    if (device.currentValue('batteryStatus') == value) { return }
    sendEventLogged(name: 'batteryStatus', value: value, descriptionText: "battery status is ${value}")
}

// ---------------------------------------------------------------------------------------------
// Three axis (optional)
// ---------------------------------------------------------------------------------------------

private void handleThreeAxis(List<Map> attrs) {
    if (!settings?.threeAxisEnable) { return }
    Map<Integer, Integer> v = [:]
    attrs.each { Map a ->
        if (a.value != null) {
            int n = zigbee.convertHexToInt(a.value as String)
            v[a.attrInt as Integer] = n > 0x7FFF ? n - 0x10000 : n      // signed 16-bit
        }
    }
    if (v.containsKey(0) && v[0] != 1) { return }     // attribute 0x0000, when present, flags a valid sample
    Integer x = v[1], y = v[2], z = v[3]
    if (x == null || y == null || z == null || (x == 0 && y == 0 && z == 0)) { return }
    String json = JsonOutput.toJson([x: x, y: y, z: z, psi: tiltAngle(x, y, z), phi: tiltAngle(y, x, z), theta: tiltAngle(z, x, y)])
    logDebug "threeAxis: ${json}"
    sendEvent(name: 'threeAxis', value: json)
}

private static BigDecimal tiltAngle(int a, int b, int c) {
    double deg = Math.toDegrees(Math.atan2(a, Math.sqrt(b * b + c * c)))
    return Math.round(deg * 10) / 10.0
}

// ---------------------------------------------------------------------------------------------
// Health (activity based; the sensor sleeps, so it is never polled)
// ---------------------------------------------------------------------------------------------

private void scheduleHealthCheck() {
    unschedule('healthCheck')
    if (offlineHrs() > 0) {
        runEvery1Hour('healthCheck')
    }
}

void healthCheck() {
    Long last = state.lastRx as Long
    if (last == null || offlineHrs() == 0) { return }
    if (now() - last > offlineHrs() * 3600000L && device.currentValue('healthStatus') != 'offline') {
        sendEventLogged(name: 'healthStatus', value: 'offline', descriptionText: "no messages from the sensor for more than ${offlineHrs()} hours")
    }
}

private void markOnline() {
    state.lastRx = now()
    if (device.currentValue('healthStatus') != 'online') {
        sendEventLogged(name: 'healthStatus', value: 'online', descriptionText: 'healthStatus is online')
    }
}

// ---------------------------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------------------------

void sendEnrollResponse() {
    sendZigbeeCommands(zigbee.enrollResponse())
}

private void sendZigbeeCommands(List<String> cmds) {
    if (!cmds) { return }
    sendHubCommand(new hubitat.device.HubMultiAction(cmds, hubitat.device.Protocol.ZIGBEE))
    logDebug "sent: ${cmds}"
}

private void sendEventLogged(Map evt) {
    sendEvent(evt)
    logInfo evt.descriptionText
}

private int offDelaySecs()    { settings?.offDelay != null ? settings.offDelay as int : DEFAULT_OFF_DELAY_SECS }
private int quietTimeoutMins() { settings?.quietTimeout != null ? settings.quietTimeout as int : DEFAULT_QUIET_TIMEOUT_MINS }
private int offlineHrs()      { settings?.offlineHours != null ? settings.offlineHours as int : DEFAULT_OFFLINE_HOURS }

private void logDebug(String msg) { if (settings?.logEnable) { log.debug "${device.displayName}: ${msg}" } }
private void logInfo(String msg)  { if (settings?.txtEnable != false) { log.info "${device.displayName}: ${msg}" } }
private void logWarn(String msg)  { log.warn "${device.displayName}: ${msg}" }