Custom driver for Sonoff SNZB-02P & SNZB-02P T&H sensors, with min/max attributes

Below is a BETA custom driver for Sonoff SNZB-02P & SNZB-02D T&H sensors, with min/max attributes. New version 1.2.2 makes minor improvements.

The calculation of min/max for temp and humidity is done by the driver, and the values can be reset daily if desired (otherwise, the just get set until the next reset).

The driver also reports:

  • battery (%)
  • health status
  • last reading
  • number of readings since being reset
  • a timestamp for reset events
/* groovylint-disable CompileStatic, LineLength, NoDef, VariableTypeRequired */
/**
 *
 *  Hubitat driver for the Sonoff SNZB-02P & SNZB-02D Zigbee temperature/humidity sensors.
 *
 *  Features:
 *    - Temperature and humidity reporting (clusters 0x0402 / 0x0405)
 *    - Battery percentage reporting (cluster 0x0001)
 *    - Health status monitoring
 *    - Software-tracked min/max temperature and humidity
 *    - Configurable reporting intervals and sensitivity thresholds
 *    - Reset of tracked min/max values via command
 *
 *  Note: All min/max tracking is performed in software from reported values.
 *
 *  Licensed under the Apache License, Version 2.0
 *
 *  Changelog:
 *    1.0.0  2026-06-03  Initial release (adapted from SNZB-02DR2 1.6.0)
 *    1.1.0  2026-06-03  Added Last Reading timestamp; added auto-reset min/max daily
 *    1.2.0  2026-06-03  Embed occurrence timestamp in Temperature/Humidity Min/Max values
 *    1.2.1  2026-06-03  Add units to all Current States for uniformity
 *    1.2.2  2026-06-03  Remove 'readings' unit from Readings Since Reset; initialize rtt on install
 */

import groovy.transform.Field
import hubitat.zigbee.zcl.DataType

@Field static final String VERSION             = '1.2.2'
@Field static final String TIME_STAMP          = '2026-06-04'

// Cluster constants
@Field static final Integer CLUSTER_POWER_CFG  = 0x0001
@Field static final Integer CLUSTER_TEMP       = 0x0402
@Field static final Integer CLUSTER_HUMIDITY   = 0x0405
@Field static final Integer PRESENCE_THRESHOLD = 4        // missed health-check cycles before "offline"
@Field static final Integer PING_TIMEOUT_MS    = 10000

metadata {
    definition(
        name:      'Sonoff SNZB-02x Temperature and Humidity Sensor',
        namespace: 'v. 1.2.2',
        author:    'John Land',
        importUrl: ''
    ) {
        capability 'Sensor'
        capability 'TemperatureMeasurement'
        capability 'RelativeHumidityMeasurement'
        capability 'Battery'
        capability 'Refresh'
        capability 'Health Check'

        // Running min/max tracked in software
        attribute 'Humidity Max',         'string'
        attribute 'Humidity Min',         'string'
        attribute 'Temperature Max',      'string'
        attribute 'Temperature Min',      'string'

        attribute 'Last Reading',         'string'
        attribute 'Min/Max Reset',        'string'
        attribute 'Readings Since Reset', 'number'

        attribute 'healthStatus',         'enum', ['unknown', 'online', 'offline']
        attribute 'rtt',                  'number'

        command   'resetMinMax',  [[name: 'Reset the Hubitat-tracked min/max temperature and humidity values']]
        command   'initialize',   [[name: 'Re-initialize driver state and re-configure device reporting']]

        fingerprint profileId:      '0104',
                    endpointId:     '01',
                    inClusters:     '0000,0001,0003,0402,0405,0020,FC57',
                    outClusters:    '000A,0019',
                    model:          'SNZB-02P',
                    manufacturer:   'SONOFF',
                    deviceJoinName: 'Sonoff SNZB-02P Temperature and Humidity Sensor'

        fingerprint profileId:      '0104',
                    endpointId:     '01',
                    inClusters:     '0000,0001,0003,0402,0405,0020,FC57',
                    outClusters:    '000A,0019',
                    model:          'SNZB-02P',
                    manufacturer:   'eWeLink',
                    deviceJoinName: 'Sonoff SNZB-02P Temperature and Humidity Sensor'

        fingerprint profileId:      '0104',
                    endpointId:     '01',
                    inClusters:     '0000,0001,0003,0402,0405,0020,FC57',
                    outClusters:    '000A,0019',
                    model:          'SNZB-02D',
                    manufacturer:   'SONOFF',
                    deviceJoinName: 'Sonoff SNZB-02D Temperature and Humidity Sensor'

        fingerprint profileId:      '0104',
                    endpointId:     '01',
                    inClusters:     '0000,0001,0003,0402,0405,0020,FC57',
                    outClusters:    '000A,0019',
                    model:          'SNZB-02D',
                    manufacturer:   'eWeLink',
                    deviceJoinName: 'Sonoff SNZB-02D Temperature and Humidity Sensor'
    }

    preferences {
        input name: 'logEnable',
              type: 'bool',
              title: '<b>Debug logging</b>',
              description: 'Enable debug log output. Automatically disabled after 24 hours.',
              defaultValue: true

        input name: 'txtEnable',
              type: 'bool',
              title: '<b>Description text logging</b>',
              description: 'Log sensor readings in the Events log.',
              defaultValue: true

        input name: 'minReportTemp',
              type: 'number',
              title: '<b>Min time between temperature reports (seconds)</b>',
              description: 'Minimum reporting interval for temperature.',
              defaultValue: 10,
              range: '1..3600'

        input name: 'maxReportTemp',
              type: 'number',
              title: '<b>Max time between temperature reports (seconds)</b>',
              description: 'Maximum reporting interval (heartbeat) for temperature.',
              defaultValue: 3600,
              range: '10..43200'

        input name: 'tempChange',
              type: 'decimal',
              title: '<b>Temperature reporting threshold (Β°C)</b>',
              description: 'Minimum change to trigger a temperature report.',
              defaultValue: 0.2,
              range: '0.1..5.0'

        input name: 'minReportHumi',
              type: 'number',
              title: '<b>Min time between humidity reports (seconds)</b>',
              description: 'Minimum reporting interval for humidity.',
              defaultValue: 10,
              range: '1..3600'

        input name: 'maxReportHumi',
              type: 'number',
              title: '<b>Max time between humidity reports (seconds)</b>',
              description: 'Maximum reporting interval (heartbeat) for humidity.',
              defaultValue: 3600,
              range: '10..43200'

        input name: 'humiChange',
              type: 'number',
              title: '<b>Humidity reporting threshold (%)</b>',
              description: 'Minimum change to trigger a humidity report.',
              defaultValue: 1,
              range: '1..10'

        input name: 'temperatureOffset',
              type: 'decimal',
              title: '<b>Temperature offset (Β°C)</b>',
              description: 'Offset applied to every temperature reading in Hubitat (does not write to device).',
              defaultValue: 0.0,
              range: '-20.0..20.0'

        input name: 'humidityOffset',
              type: 'decimal',
              title: '<b>Humidity offset (%)</b>',
              description: 'Offset applied to every humidity reading in Hubitat (does not write to device).',
              defaultValue: 0.0,
              range: '-20.0..20.0'

        input name: 'autoResetEnable',
              type: 'bool',
              title: '<b>Auto-reset min/max daily</b>',
              description: 'When enabled, the driver-tracked min/max values are reset automatically each day at the time set below.',
              defaultValue: false

        input name: 'autoResetTime',
              type: 'time',
              title: '<b>Auto-reset time</b>',
              description: 'Time of day at which the driver min/max is automatically reset (only used when auto-reset is enabled).',
              defaultValue: '00:00'
    }
}

// ── Lifecycle ────────────────────────────────────────────────────────────────

def installed() {
    logInfo 'installed()'
    initVars(true)
    configure()
}

def updated() {
    logInfo 'updated()'
    if (logEnable) { runIn(86400, 'logsOff') }
    rescheduleAutoReset()
}

def configure() {
    logInfo 'configure()'
    List<String> cmds = []

    // Configure temperature reporting
    Integer tempDelta = Math.round((safeDouble(tempChange, 0.2) * 100) as double) as Integer
    cmds += zigbee.configureReporting(CLUSTER_TEMP, 0x0000, DataType.INT16,
        safeInt(minReportTemp, 10), safeInt(maxReportTemp, 3600), tempDelta, [:], 200)

    // Configure humidity reporting
    Integer humiDelta = Math.round((safeDouble(humiChange, 1.0) * 100) as double) as Integer
    cmds += zigbee.configureReporting(CLUSTER_HUMIDITY, 0x0000, DataType.UINT16,
        safeInt(minReportHumi, 10), safeInt(maxReportHumi, 3600), humiDelta, [:], 200)

    // Configure battery reporting
    cmds += zigbee.configureReporting(CLUSTER_POWER_CFG, 0x0021, DataType.UINT8,
        60, 14400, 1, [:], 200)

    // Read current values
    cmds += zigbee.readAttribute(CLUSTER_TEMP,      0x0000, [:], 200)
    cmds += zigbee.readAttribute(CLUSTER_HUMIDITY,  0x0000, [:], 200)
    cmds += zigbee.readAttribute(CLUSTER_POWER_CFG, 0x0021, [:], 200)

    scheduleHealthCheck()
    sendZigbeeCommands(cmds)
}

def initialize() {
    logInfo 'initialize()'
    unschedule()
    initVars(true)
    configure()
}

def refresh() {
    logInfo 'refresh()'
    List<String> cmds = []
    cmds += zigbee.readAttribute(CLUSTER_TEMP,      0x0000, [:], 200)
    cmds += zigbee.readAttribute(CLUSTER_HUMIDITY,  0x0000, [:], 200)
    cmds += zigbee.readAttribute(CLUSTER_POWER_CFG, 0x0021, [:], 200)
    cmds += zigbee.readAttribute(CLUSTER_POWER_CFG, 0x0020, [:], 200)
    sendZigbeeCommands(cmds)
}

def ping() {
    logDebug 'ping()'
    state.pingTime = now()
    scheduleCommandTimeout()
    sendZigbeeCommands(zigbee.readAttribute(0x0000, 0x0001, [:], 0))
}

// ── Commands ─────────────────────────────────────────────────────────────────

def resetMinMax() {
    logInfo 'Resetting min/max tracking'
    state.minTemp        = null
    state.maxTemp        = null
    state.minHumi        = null
    state.maxHumi        = null
    state.readingCount   = 0
    String ts = new Date().format('yyyy-MM-dd HH:mm:ss', location.timeZone)
    sendEvent(name: 'Min/Max Reset',        value: ts,   descriptionText: "Min/max reset at ${ts}")
    sendEvent(name: 'Readings Since Reset', value: 0,    descriptionText: 'Reading counter reset to 0')
    sendEvent(name: 'Temperature Min',      value: '--', descriptionText: 'Min temperature reset')
    sendEvent(name: 'Temperature Max',      value: '--', descriptionText: 'Max temperature reset')
    sendEvent(name: 'Humidity Min',         value: '--', descriptionText: 'Min humidity reset')
    sendEvent(name: 'Humidity Max',         value: '--', descriptionText: 'Max humidity reset')
}

// ── Parse ─────────────────────────────────────────────────────────────────────

def parse(String description) {
    setPresent()

    if (!(description?.startsWith('catchall:') || description?.startsWith('read attr -'))) {
        logDebug "unhandled description: ${description}"
        return
    }

    Map descMap = zigbee.parseDescriptionAsMap(description)
    logDebug "parse() descMap = ${descMap}"

    switch (descMap.clusterInt as Integer) {
        case CLUSTER_POWER_CFG:
            parsePowerCluster(descMap)
            break
        case CLUSTER_TEMP:
            parseTempCluster(descMap)
            break
        case CLUSTER_HUMIDITY:
            parseHumiCluster(descMap)
            break
        case 0x0000:
            // Basic cluster – used for ping response
            if (descMap.attrInt == 0x0001 && descMap.value) {
                def elapsed = now() - (state.pingTime ?: 0)
                if (elapsed < PING_TIMEOUT_MS && elapsed > 0) {
                    unschedule('commandTimeout')
                    sendEvent(name: 'rtt', value: elapsed, unit: 'ms', descriptionText: "Round-trip time ${elapsed} ms")
                    logInfo "ping RTT = ${elapsed} ms"
                }
            }
            break
        default:
            logDebug "unhandled cluster: 0x${Integer.toHexString(descMap.clusterInt ?: 0)}"
            break
    }
}

// ── Cluster parsers ──────────────────────────────────────────────────────────

private void parsePowerCluster(Map descMap) {
    if (descMap.attrInt == 0x0021 && descMap.value && descMap.value.length() <= 8) {
        // Battery percentage (value is reported as twice the percentage)
        Integer raw = (Long.parseLong(descMap.value, 16) & 0xFF) as Integer
        Integer pct = Math.min(100, Math.round(raw / 2.0) as Integer)
        sendEvent(name: 'battery', value: pct, unit: '%',
                  descriptionText: "${device.displayName} battery is ${pct}%", type: 'physical')
        logInfo "battery ${pct}%"
    } else if (descMap.attrInt == 0x0020 && descMap.value && descMap.value.length() <= 8) {
        // Battery voltage (raw in 100 mV units)
        Integer raw = (Long.parseLong(descMap.value, 16) & 0xFF) as Integer
        double volts = raw / 10.0
        // Derive a percentage from voltage range 2.1V-3.0V
        Integer pct = Math.min(100, Math.max(1, Math.round(((volts - 2.1) / 0.9) * 100) as Integer))
        sendEvent(name: 'battery', value: pct, unit: '%',
                  descriptionText: "${device.displayName} battery ~${pct}% (${volts}V)", type: 'physical')
        logInfo "battery voltage ${volts}V (~${pct}%)"
    }
}

private void parseTempCluster(Map descMap) {
    // Accept attrInt 0x0000 from read-response or null from some catchall frames
    if (descMap.attrInt != null && descMap.attrInt != 0x0000) { return }
    if (!descMap.value || descMap.value.length() > 8) { return }
    Integer raw = (Long.parseLong(descMap.value, 16) & 0xFFFF) as Integer
    if (raw == 0x8000) { logDebug 'temperature: invalid sentinel value'; return }
    // Two's complement for negative temperatures
    if (raw > 32767) { raw = raw - 65536 }
    double tempC = raw / 100.0
    double tempAdjusted = tempC + safeDouble(temperatureOffset, 0.0)

    double display
    String unit
    if (location.temperatureScale == 'F') {
        display = Math.round(((tempAdjusted * 1.8) + 32 - 0.05) * 10) / 10
        unit = '\u00B0F'
    } else {
        display = Math.round((tempAdjusted - 0.05) * 10) / 10
        unit = '\u00B0C'
    }

    sendEvent(name: 'temperature', value: display, unit: unit,
              descriptionText: "${device.displayName} temperature is ${display}${unit}", type: 'physical')
    logInfo "temperature ${display}${unit}"
    updateMinMax('temperature', tempAdjusted)
    String ts = new Date().format('yyyy-MM-dd HH:mm:ss', location.timeZone)
    sendEvent(name: 'Last Reading', value: ts, descriptionText: "Last reading at ${ts}")
}

private void parseHumiCluster(Map descMap) {
    // Accept attrInt 0x0000 from read-response or null from some catchall frames
    if (descMap.attrInt != null && descMap.attrInt != 0x0000) { return }
    if (!descMap.value || descMap.value.length() > 8) { return }
    Integer raw = (Long.parseLong(descMap.value, 16) & 0xFFFF) as Integer
    double humiPct = raw / 100.0
    double humiAdjusted = humiPct + safeDouble(humidityOffset, 0.0)
    humiAdjusted = Math.max(0.0, Math.min(100.0, humiAdjusted))
    Integer display = Math.round(humiAdjusted) as Integer

    sendEvent(name: 'humidity', value: display, unit: '%',
              descriptionText: "${device.displayName} humidity is ${display}%", type: 'physical')
    logInfo "humidity ${display}%"
    updateMinMax('humidity', humiAdjusted)
}

private String formatTemp(double tempC) {
    if (location.temperatureScale == 'F') {
        return "${Math.round((tempC * 1.8 + 32) * 10) / 10}\u00B0F"
    }
    return "${Math.round(tempC * 10) / 10}\u00B0C"
}

// ── Min/Max tracking ─────────────────────────────────────────────────────────

private void updateMinMax(String type, double value) {
    if (type == 'temperature') {
        // Increment the reading counter once per temp+humidity pair
        state.readingCount = (state.readingCount ?: 0) + 1
        sendEvent(name: 'Readings Since Reset', value: state.readingCount,
                  descriptionText: "${state.readingCount} readings since last reset")
        double rounded = Math.round(value * 10) / 10
        String unit = location.temperatureScale == 'F' ? '\u00B0F' : '\u00B0C'
        double displayVal = location.temperatureScale == 'F' ? Math.round(((value * 1.8) + 32) * 10) / 10 : rounded

        if (state.minTemp == null || value < (state.minTemp as double)) {
            state.minTemp = value
            String ts = new Date().format('MM-dd HH:mm', location.timeZone)
            sendEvent(name: 'Temperature Min', value: "${displayVal}${unit} @ ${ts}",
                      descriptionText: "Min temperature is ${displayVal}${unit} (at ${ts}, since last reset)")
            logInfo "New min temperature: ${displayVal}${unit} @ ${ts}"
        }
        if (state.maxTemp == null || value > (state.maxTemp as double)) {
            state.maxTemp = value
            String ts = new Date().format('MM-dd HH:mm', location.timeZone)
            sendEvent(name: 'Temperature Max', value: "${displayVal}${unit} @ ${ts}",
                      descriptionText: "Max temperature is ${displayVal}${unit} (at ${ts}, since last reset)")
            logInfo "New max temperature: ${displayVal}${unit} @ ${ts}"
        }
    } else if (type == 'humidity') {
        Integer roundedPct = Math.round(value) as Integer
        if (state.minHumi == null || value < (state.minHumi as double)) {
            state.minHumi = value
            String ts = new Date().format('MM-dd HH:mm', location.timeZone)
            sendEvent(name: 'Humidity Min', value: "${roundedPct}% @ ${ts}",
                      descriptionText: "Min humidity is ${roundedPct}% (at ${ts}, since last reset)")
            logInfo "New min humidity: ${roundedPct}% @ ${ts}"
        }
        if (state.maxHumi == null || value > (state.maxHumi as double)) {
            state.maxHumi = value
            String ts = new Date().format('MM-dd HH:mm', location.timeZone)
            sendEvent(name: 'Humidity Max', value: "${roundedPct}% @ ${ts}",
                      descriptionText: "Max humidity is ${roundedPct}% (at ${ts}, since last reset)")
            logInfo "New max humidity: ${roundedPct}% @ ${ts}"
        }
    }
}

// ── Auto-reset scheduling ─────────────────────────────────────────────────────

private void rescheduleAutoReset() {
    unschedule('autoResetMinMax')
    if (!autoResetEnable) {
        logInfo 'Auto-reset disabled'
        return
    }
    try {
        def t = timeToday(autoResetTime, location.timeZone)
        String cronExpr = "0 ${t.minutes} ${t.hours} * * ? *"
        schedule(cronExpr, 'autoResetMinMax')
        logInfo "Auto-reset scheduled daily at ${autoResetTime} (cron: ${cronExpr})"
    } catch (e) {
        logWarn "Auto-reset scheduling failed: ${e.message}"
    }
}

def autoResetMinMax() {
    logInfo 'Auto-reset: resetting driver min/max tracking'
    resetMinMax()
}

// ── Health check ──────────────────────────────────────────────────────────────

def setPresent() {
    if ((device.currentValue('healthStatus') ?: 'unknown') != 'online') {
        sendEvent(name: 'healthStatus', value: 'online', type: 'digital')
        logInfo 'device is present (online)'
    }
    state.missedChecks = 0
}

def deviceHealthCheck() {
    state.missedChecks = (state.missedChecks ?: 0) + 1
    if (state.missedChecks > PRESENCE_THRESHOLD) {
        if ((device.currentValue('healthStatus', true) ?: 'unknown') != 'offline') {
            sendEvent(name: 'healthStatus', value: 'offline', type: 'digital')
            logWarn 'device has not reported β€” marking offline'
        }
    } else {
        logDebug "health check β€” ok (missed=${state.missedChecks})"
    }
}

private void scheduleHealthCheck() {
    Random rnd = new Random()
    schedule("${rnd.nextInt(59)} ${rnd.nextInt(59)} 1/3 * * ? *", 'deviceHealthCheck')
    rescheduleAutoReset()
}

private void scheduleCommandTimeout() {
    runIn(10, 'commandTimeout')
}

void commandTimeout() {
    logWarn 'command timed out (no response from device)'
    sendEvent(name: 'rtt', value: -1, unit: 'ms', descriptionText: 'No ping response (timeout)', type: 'digital')
}

// ── Init helpers ─────────────────────────────────────────────────────────────

private void initVars(boolean fullInit) {
    if (fullInit) {
        state.minTemp      = null
        state.maxTemp      = null
        state.minHumi      = null
        state.maxHumi      = null
        state.missedChecks = 0
        state.pingTime     = 0
        state.readingCount = 0
        String ts = new Date().format('yyyy-MM-dd HH:mm:ss', location.timeZone)
        sendEvent(name: 'healthStatus',         value: 'unknown')
        sendEvent(name: 'Last Reading',         value: '--', descriptionText: 'No reading yet')
        sendEvent(name: 'Min/Max Reset',        value: ts,   descriptionText: "Min/max reset at ${ts}")
        sendEvent(name: 'Readings Since Reset', value: 0,    descriptionText: 'Reading counter initialized')
        sendEvent(name: 'Temperature Min',      value: '--')
        sendEvent(name: 'Temperature Max',      value: '--')
        sendEvent(name: 'Humidity Min',         value: '--')
        sendEvent(name: 'Humidity Max',         value: '--')
        sendEvent(name: 'rtt',                  value: 0,    unit: 'ms', descriptionText: 'Round-trip time not yet measured', type: 'digital')
    }
    state.driverVersion = "${VERSION} ${TIME_STAMP}"
}

// ── Utilities ─────────────────────────────────────────────────────────────────

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

private double safeDouble(val, double defaultVal = 0.0) {
    try { return val != null ? (val as double) : defaultVal } catch (e) { return defaultVal }
}

private int safeInt(val, int defaultVal = 0) {
    try { return val != null ? (val as int) : defaultVal } catch (e) { return defaultVal }
}

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

private void logDebug(String msg) { if (logEnable) { log.debug "${device.displayName} ${msg}" } }
private void logInfo(String msg)  { if (txtEnable) { log.info  "${device.displayName} ${msg}" } }
private void logWarn(String msg)  {                  log.warn  "${device.displayName} ${msg}"   }

2 Likes

I got a bit excited when I saw this driver, but then I deflated when I noted it was for an SNZB-02P, but my unit is an SNZB-O2D. I’m using an HE system driver specific to Sonoff and it seems operational.
Can you highlight what the biggest benefits of your work are here? Would adding my device fingerprint to your beta driver and switching to it give value?

This driver has been working for me on a Sonoff SNZB-O2D. The benefit to me is that the driver computes and presents timestamped minimum and maximum values for temperature and humidity; example:

One downside of the form β€œ37% @ 06-04 09:10β€œ or β€œ72.0Β°F @ 06-04 06:25β€œ is that the data value and the date have to be teased apart in a rule to be independently tested/evaluated, but I didn’t want to clutter up the device page further. Although I’m currently experimenting with a version that enables or disables the display of separated T&H min/max values as attributes that can be used in rules.

wow - thats interesting! I can see the value of that, no guessing as when that temp/RH value occurred. :slight_smile: I don’t have a current need as I just pump data at grafana via influx and have to go there to sus out the occurrance stamp but thats rare for me. Appreciate the response!

Post #1 now has a slightly improved v. 1.2.2 that is β€œofficially” (for a Beta version) for a Sonoff SNZB-02D T&H sensor.

2 Likes