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}" }
