Below is a custom driver for the Sonoff SNZB-02DR2 (see image below). This device has native min/max temp and humidity data; the driver also computes min/max values with a timestamp.
EDIT 2026-06-04: my coding assistant, Claude AI, made a silly error in the prior version, but fortunately my code review assistant, ChatGPT, found the error and Claude fixed it. So “Comfort Temp Min” now shows real data, and several of the other data fields have been corrected (they were off by one slot in Current States). However, we determined that the “Reset Device Min Max” button could not work ever, so it has been removed.
Notes:
- The device, through its native app, can be used to report the T&H data of a compatible remote Sonoff sensor; that capability is likely not going to be available via Zigbee.
- I’m still testing whether the SNZB-02DR2 behaves well in a Zigbee mesh. It has dropped off a couple of times in the past 2 days, requiring re-pairing, but I was pushing its back button a lot to force refreshes. I’m going to wait a week or so to see if it is stable before buying more.
/* groovylint-disable CompileStatic, LineLength, NoDef, VariableTypeRequired */
/**
* Sonoff SNZB-02DR2 Temperature and Humidity Sensor (AirGuard TH)
*
* Hubitat driver for the Sonoff SNZB-02DR2 Zigbee temperature/humidity sensor.
*
* Features:
* - Temperature and humidity reporting (clusters 0x0402 / 0x0405)
* - Battery percentage reporting (cluster 0x0001)
* - Health status monitoring
* - Software-tracked min/max temperature and humidity (mirrors the device LCD 24-hr extremes)
* - Read/write of FC11 comfort-zone thresholds (trigger ❄️ and 🔥 icons on the LCD)
* - Temperature unit setting (°C / °F) via FC11 cluster
* - Temperature and humidity calibration offsets via FC11 cluster
* - Configurable reporting intervals and sensitivity thresholds
* - Reset of tracked min/max values via command
*
* Important notes about min/max:
* The 24-hour min/max values shown on the physical LCD display are calculated
* entirely inside the device firmware and are NOT transmitted over Zigbee.
* This driver tracks its own running min/max from every temperature and humidity
* report it receives, which will closely mirror the device display. Use the
* "Reset Min/Max" command to start a fresh tracking period.
*
* FC11 cluster comfort thresholds:
* The device shows ❄️ when temperature < comfortTempMin
* The device shows 🔥 when temperature > comfortTempMax
* These are writable via this driver's preferences.
* NOTE: The device is a sleepy end-device. Wake it up (press the back button)
* before saving preferences that write to FC11, otherwise the write will time out.
*
* Licensed under the Apache License, Version 2.0
*
* Changelog:
* 1.0.0 2025-06-02 Initial release
* 1.1.0 2026-06-02 Added Min/Max Reset timestamp and Readings Since Reset counter
* 1.2.0 2026-06-02 Decode FC11 0x2008-0x200D device-native 24-hr min/max; fix null display
* 1.6.0 2026-06-02 Add comfortTempMin sentinel; add resetDeviceMinMax command
* 1.7.0 2026-06-03 Added scheduled auto-reset of driver min/max at a user-specified time
* 1.8.0 2026-06-03 Embed occurrence timestamp in Temperature/Humidity Min/Max values
* 1.9.0 2026-06-04 Fix FC11 attribute map (comfortTempMin was 0x0002, correct is 0x0004;
* tempUnit moves from 0x0001 to 0x0007; calibration moves to 0x2003/0x2004).
* Replace resetDeviceMinMax() with readDeviceMinMax() — device 24-hr
* min/max is firmware-maintained and not remotely resettable.
* 1.10.0 2026-06-04 Remove readDeviceMinMax() — fully redundant with Refresh.
*/
import groovy.transform.Field
import hubitat.zigbee.zcl.DataType
@Field static final String VERSION = '1.10.0'
@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 CLUSTER_FC11 = 0xFC11 // Sonoff private cluster
// FC11 attribute IDs — verified against zigbee-herdsman-converters sonoff.ts
// (https://github.com/Koenkk/zigbee-herdsman-converters/blob/master/src/devices/sonoff.ts)
// NOTE: 0x0001 and 0x0002 are NOT used by SNZB-02DR2 comfort attributes.
@Field static final Integer FC11_ATTR_COMFORT_TEMP_MAX = 0x0003 // INT16, value * 100 (°C × 100)
@Field static final Integer FC11_ATTR_COMFORT_TEMP_MIN = 0x0004 // INT16, value * 100 (°C × 100)
@Field static final Integer FC11_ATTR_COMFORT_HUMI_MIN = 0x0005 // UINT16, value * 100 (% × 100)
@Field static final Integer FC11_ATTR_COMFORT_HUMI_MAX = 0x0006 // UINT16, value * 100 (% × 100)
@Field static final Integer FC11_ATTR_TEMP_UNIT = 0x0007 // UINT8, 0=Celsius 1=Fahrenheit
@Field static final Integer FC11_ATTR_TEMP_CALIBRATION = 0x2003 // INT16, offset * 100
@Field static final Integer FC11_ATTR_HUMI_CALIBRATION = 0x2004 // INT16, offset * 100
// FC11 device-native 24-hr min/max (confirmed by Zigbee sniff, unsolicited ~hourly)
// Declared as int (not Integer) so if/else comparisons work reliably in Groovy
@Field static final int FC11_MAX_TEMP = 0x2008 // INT16 max temp x100 degC
@Field static final int FC11_MIN_TEMP = 0x2009 // INT16 min temp x100 degC
@Field static final int FC11_PREV_TEMP = 0x200A // INT16 reference temp
@Field static final int FC11_MAX_HUMI = 0x200B // UINT16 max humidity x100 %
@Field static final int FC11_MIN_HUMI = 0x200C // UINT16 min humidity x100 %
@Field static final int FC11_PREV_HUMI = 0x200D // UINT16 reference humidity
@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-02DR2 Temperature and Humidity Sensor',
namespace: 'v. 1.10.0',
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 'Min/Max Reset', 'string'
attribute 'Last Reading', 'string'
attribute 'Readings Since Reset', 'number'
// Device-native 24-hr min/max from FC11 cluster (populated by device firmware)
// Declared as string so a '--' placeholder shows while waiting for first report
attribute 'Device Humidity Max', 'string'
attribute 'Device Humidity Min', 'string'
attribute 'Device Temp Max', 'string'
attribute 'Device Temp Min', 'string'
// Comfort zone thresholds (readable state, set via preferences)
attribute 'comfortTempMin', 'string'
attribute 'comfortTempMax', 'string'
attribute 'comfortHumiMin', 'string'
attribute 'comfortHumiMax', 'string'
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,FC11',
outClusters: '000A,0019',
model: 'SNZB-02DR2',
manufacturer: 'SONOFF',
deviceJoinName: 'Sonoff SNZB-02DR2 AirGuard TH'
fingerprint profileId: '0104',
endpointId: '01',
inClusters: '0000,0001,0003,0402,0405,0020,FC57,FC11',
outClusters: '000A,0019',
model: 'SNZB-02DR2',
manufacturer: 'eWeLink',
deviceJoinName: 'Sonoff SNZB-02DR2 AirGuard TH'
}
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: 'pref_comfortTempMin',
type: 'decimal',
title: '<b>Comfort temperature minimum (°C)</b>',
description: 'Device shows ❄️ below this value. Always enter in °C regardless of hub scale. Wake device before saving.',
defaultValue: 18.0,
range: '-10..60'
input name: 'pref_comfortTempMax',
type: 'decimal',
title: '<b>Comfort temperature maximum (°C)</b>',
description: 'Device shows 🔥 above this value. Always enter in °C regardless of hub scale. Wake device before saving.',
defaultValue: 26.0,
range: '-10..60'
input name: 'pref_comfortHumiMin',
type: 'number',
title: '<b>Comfort humidity minimum (%)</b>',
description: 'Lower end of the comfort humidity range. Range 0 to 99.',
defaultValue: 40,
range: '0..99'
input name: 'pref_comfortHumiMax',
type: 'number',
title: '<b>Comfort humidity maximum (%)</b>',
description: 'Upper end of the comfort humidity range. Range 1 to 100.',
defaultValue: 60,
range: '1..100'
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') }
// Schedule or cancel the daily auto-reset
rescheduleAutoReset()
// Write FC11 comfort thresholds to device (device must be awake)
List<String> cmds = []
cmds += writeFC11ComfortThresholds()
if (cmds) { sendZigbeeCommands(cmds) }
}
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)
// Read current FC11 comfort thresholds
cmds += zigbee.readAttribute(CLUSTER_FC11, FC11_ATTR_COMFORT_TEMP_MIN, [mfgCode: '0x1286'], 200)
cmds += zigbee.readAttribute(CLUSTER_FC11, FC11_ATTR_COMFORT_TEMP_MAX, [mfgCode: '0x1286'], 200)
cmds += zigbee.readAttribute(CLUSTER_FC11, FC11_ATTR_COMFORT_HUMI_MIN, [mfgCode: '0x1286'], 200)
cmds += zigbee.readAttribute(CLUSTER_FC11, FC11_ATTR_COMFORT_HUMI_MAX, [mfgCode: '0x1286'], 200)
scheduleHealthCheck()
sendZigbeeCommands(cmds)
runIn(2, 'updated')
}
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)
cmds += zigbee.readAttribute(CLUSTER_FC11, FC11_ATTR_COMFORT_TEMP_MIN, [mfgCode: '0x1286'], 200)
cmds += zigbee.readAttribute(CLUSTER_FC11, FC11_ATTR_COMFORT_TEMP_MAX, [mfgCode: '0x1286'], 200)
cmds += zigbee.readAttribute(CLUSTER_FC11, FC11_ATTR_COMFORT_HUMI_MIN, [mfgCode: '0x1286'], 200)
cmds += zigbee.readAttribute(CLUSTER_FC11, FC11_ATTR_COMFORT_HUMI_MAX, [mfgCode: '0x1286'], 200)
// Read device-native min/max (FC11 0x2008-0x200D)
// These are normally pushed unsolicited ~hourly; reading them explicitly
// lets Refresh populate them without waiting for the next automatic report
cmds += zigbee.readAttribute(CLUSTER_FC11, FC11_MAX_TEMP, [mfgCode: '0x1286'], 200)
cmds += zigbee.readAttribute(CLUSTER_FC11, FC11_MIN_TEMP, [mfgCode: '0x1286'], 200)
cmds += zigbee.readAttribute(CLUSTER_FC11, FC11_MAX_HUMI, [mfgCode: '0x1286'], 200)
cmds += zigbee.readAttribute(CLUSTER_FC11, FC11_MIN_HUMI, [mfgCode: '0x1286'], 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')
}
// ── Auto-reset scheduling ─────────────────────────────────────────────────────
private void rescheduleAutoReset() {
unschedule('autoResetMinMax')
if (!autoResetEnable) {
logInfo 'Auto-reset disabled'
return
}
// Parse the user-supplied time preference and build a daily cron expression
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()
}
// ── 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 CLUSTER_FC11:
parseFC11Cluster(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 void parseFC11Cluster(Map descMap) {
// Process additional attributes packed into the same frame
if (descMap.additionalAttrs) {
descMap.additionalAttrs.each { Map aa ->
String v = aa.value as String
if (v && v.length() <= 8) {
parseFC11Attr(aa.attrInt as int, v)
}
}
}
String val = descMap.value as String
if (!val || val.length() > 8) {
if (val && val.length() > 8) { logDebug "FC11 skipping blob len=${val.length()}" }
return
}
parseFC11Attr(descMap.attrInt as int, val)
}
// Separate method so attribute comparisons use plain int arithmetic (avoids Groovy
// switch/Integer boxing issues that silently fail for values > 0x00FF)
private void parseFC11Attr(int attrInt, String hexVal) {
Integer raw = (Long.parseLong(hexVal, 16) & 0xFFFF) as Integer
if (attrInt == (FC11_ATTR_TEMP_UNIT as int)) {
logInfo "FC11 temperature unit: ${raw == 1 ? 'Fahrenheit' : 'Celsius'}"
} else if (attrInt == (FC11_ATTR_COMFORT_TEMP_MIN as int)) {
Integer signed = raw > 32767 ? raw - 65536 : raw
double valC = signed / 100.0
String display = formatTemp(valC)
sendEvent(name: 'comfortTempMin', value: display,
descriptionText: "Comfort temp min = ${display} (${valC}\u00B0C device setting)")
logInfo "FC11 comfort temp min = ${display}"
} else if (attrInt == (FC11_ATTR_COMFORT_TEMP_MAX as int)) {
Integer signed = raw > 32767 ? raw - 65536 : raw
double valC = signed / 100.0
String display = formatTemp(valC)
sendEvent(name: 'comfortTempMax', value: display,
descriptionText: "Comfort temp max = ${display} (${valC}\u00B0C device setting)")
logInfo "FC11 comfort temp max = ${display}"
} else if (attrInt == (FC11_ATTR_COMFORT_HUMI_MIN as int)) {
double val = raw / 100.0
String humiMinDisplay = "${Math.round(val)}%"
sendEvent(name: 'comfortHumiMin', value: humiMinDisplay,
descriptionText: "Comfort humidity min = ${humiMinDisplay}")
logInfo "FC11 comfort humidity min = ${humiMinDisplay}"
} else if (attrInt == (FC11_ATTR_COMFORT_HUMI_MAX as int)) {
double val = raw / 100.0
String humiMaxDisplay = "${Math.round(val)}%"
sendEvent(name: 'comfortHumiMax', value: humiMaxDisplay,
descriptionText: "Comfort humidity max = ${humiMaxDisplay}")
logInfo "FC11 comfort humidity max = ${humiMaxDisplay}"
} else if (attrInt == (FC11_ATTR_TEMP_CALIBRATION as int)) {
Integer signed = raw > 32767 ? raw - 65536 : raw
logInfo "FC11 temp calibration = ${String.format('%.2f', signed / 100.0)}\u00B0C"
} else if (attrInt == (FC11_ATTR_HUMI_CALIBRATION as int)) {
Integer signed = raw > 32767 ? raw - 65536 : raw
logInfo "FC11 humidity calibration = ${String.format('%.2f', signed / 100.0)}%"
} else if (attrInt == FC11_MAX_TEMP) {
Integer signed = raw > 32767 ? raw - 65536 : raw
String display = formatTemp(signed / 100.0)
sendEvent(name: 'Device Temp Max', value: display,
descriptionText: "Device max temp = ${display}")
logInfo "FC11 device max temp = ${display}"
} else if (attrInt == FC11_MIN_TEMP) {
Integer signed = raw > 32767 ? raw - 65536 : raw
String display = formatTemp(signed / 100.0)
sendEvent(name: 'Device Temp Min', value: display,
descriptionText: "Device min temp = ${display}")
logInfo "FC11 device min temp = ${display}"
} else if (attrInt == FC11_PREV_TEMP) {
Integer signed = raw > 32767 ? raw - 65536 : raw
logInfo "FC11 0x200A ref temp = ${formatTemp(signed / 100.0)}"
} else if (attrInt == FC11_MAX_HUMI) {
String display = "${Math.round(raw / 100.0)}%"
sendEvent(name: 'Device Humidity Max', value: display,
descriptionText: "Device max humidity = ${display}")
logInfo "FC11 device max humidity = ${display}"
} else if (attrInt == FC11_MIN_HUMI) {
String display = "${Math.round(raw / 100.0)}%"
sendEvent(name: 'Device Humidity Min', value: display,
descriptionText: "Device min humidity = ${display}")
logInfo "FC11 device min humidity = ${display}"
} else if (attrInt == FC11_PREV_HUMI) {
logInfo "FC11 0x200D ref humidity = ${Math.round(raw / 100.0)}%"
} else {
logDebug "FC11 unhandled attr 0x${Integer.toHexString(attrInt)} = ${raw}"
}
}
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}"
}
}
}
// ── FC11 write helpers ───────────────────────────────────────────────────────
private List<String> writeFC11ComfortThresholds() {
List<String> cmds = []
// comfort_temperature_min — INT16, scaled × 100
Integer tempMinRaw = Math.round(safeDouble(pref_comfortTempMin, 18.0) * 100) as Integer
cmds += zigbee.writeAttribute(CLUSTER_FC11, FC11_ATTR_COMFORT_TEMP_MIN,
DataType.INT16, tempMinRaw, [mfgCode: '0x1286'], 200)
// comfort_temperature_max — INT16, scaled × 100
Integer tempMaxRaw = Math.round(safeDouble(pref_comfortTempMax, 26.0) * 100) as Integer
cmds += zigbee.writeAttribute(CLUSTER_FC11, FC11_ATTR_COMFORT_TEMP_MAX,
DataType.INT16, tempMaxRaw, [mfgCode: '0x1286'], 200)
// comfort_humidity_min — UINT16, scaled × 100
Integer humiMinRaw = Math.round(safeInt(pref_comfortHumiMin, 40) * 100) as Integer
cmds += zigbee.writeAttribute(CLUSTER_FC11, FC11_ATTR_COMFORT_HUMI_MIN,
DataType.UINT16, humiMinRaw, [mfgCode: '0x1286'], 200)
// comfort_humidity_max — UINT16, scaled × 100
Integer humiMaxRaw = Math.round(safeInt(pref_comfortHumiMax, 60) * 100) as Integer
cmds += zigbee.writeAttribute(CLUSTER_FC11, FC11_ATTR_COMFORT_HUMI_MAX,
DataType.UINT16, humiMaxRaw, [mfgCode: '0x1286'], 200)
logDebug "Writing FC11 comfort thresholds: tempMin=${tempMinRaw / 100.0} tempMax=${tempMaxRaw / 100.0} humiMin=${humiMinRaw / 100.0} humiMax=${humiMaxRaw / 100.0}"
return cmds
}
// ── 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: 'timeout', unit: '', descriptionText: 'No ping response', 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: '--')
// Device-native min/max: '--' keeps them visible in Current States
// while waiting for the device's hourly FC11 report
sendEvent(name: 'Device Temp Max', value: '--', descriptionText: 'Waiting for device report')
sendEvent(name: 'Device Temp Min', value: '--', descriptionText: 'Waiting for device report')
sendEvent(name: 'Device Humidity Max', value: '--', descriptionText: 'Waiting for device report')
sendEvent(name: 'Device Humidity Min', value: '--', descriptionText: 'Waiting for device report')
// Comfort thresholds: also seed with '--' so they appear before first device report
sendEvent(name: 'comfortTempMin', value: '--')
sendEvent(name: 'comfortTempMax', value: '--')
sendEvent(name: 'comfortHumiMin', value: '--')
sendEvent(name: 'comfortHumiMax', value: '--')
}
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}" }

