Sonoff SNZB-02M – Hubitat driver

I just bought one of these on Amazon, and I did not find a driver that utilized all of it's capabilities, so I ask AI (Grok 4.5) to build one for me. This is what I got. It works for me.

I am not a programmer, so you will only find this here. It is not on GitHub, or Hubitat Package Manager.

Code follows :down_arrow:

/**

  • Sonoff SNZB-02M – Hubitat driver
  • Temperature / Humidity / Pressure / Battery / Dew point / VPD / FC11
  • Author: Grok 4.5, built by xAI.
  • Based on community work for Sonoff SNZB-02M on Hubitat
  • Endian handling: Hubitat parseDescriptionAsMap returns logical (BE) multi-byte values
  • Capabilities:
    • TemperatureMeasurement
    • RelativeHumidityMeasurement
    • PressureMeasurement
    • Battery
    • Sensor
    • Refresh
    • Configuration
    • HealthCheck
  • Hubitat parseDescriptionAsMap already returns multi-byte values in
  • logical (big-endian) hex — do NOT little-endian swap.
    */

metadata {
definition(
name: "Sonoff SNZB-02M Temp Humidity Pressure",
namespace: "custom.sonoff",
author: "Custom",
importUrl: ""
) {
capability "Sensor"
capability "TemperatureMeasurement"
capability "RelativeHumidityMeasurement"
capability "PressureMeasurement"
capability "Battery"
capability "Refresh"
capability "Configuration"
capability "HealthCheck"

    attribute "lastCheckin", "string"
    attribute "healthStatus", "enum", ["online", "offline"]
    attribute "dewPoint", "number"
    attribute "vpd", "number"
    attribute "temperatureCalibration", "number"
    attribute "humidityCalibration", "number"
    attribute "pressureCalibration", "number"
    attribute "comfortTempMin", "number"
    attribute "comfortTempMax", "number"
    attribute "comfortHumidityMin", "number"
    attribute "comfortHumidityMax", "number"

    fingerprint profileId: "0104",
                inClusters: "0000,0001,0003,0020,0402,0403,0405,FC57,FC11",
                outClusters: "0019",
                manufacturer: "SONOFF",
                model: "SNZB-02M",
                deviceJoinName: "Sonoff SNZB-02M"
}

preferences {
    input name: "tempUnit", type: "enum", title: "Temperature unit",
          options: ["C": "Celsius (°C)", "F": "Fahrenheit (°F)"],
          defaultValue: "C", required: true
    input name: "tempOffset", type: "decimal", title: "Temperature offset (°C)",
          range: "-10..10", defaultValue: 0.0
    input name: "humOffset", type: "decimal", title: "Humidity offset (%)",
          range: "-20..20", defaultValue: 0.0
    input name: "pressOffset", type: "decimal", title: "Pressure offset (hPa)",
          range: "-50..50", defaultValue: 0.0

    input name: "devTempCal", type: "decimal", title: "Device temp calibration (°C)",
          range: "-20..60", defaultValue: 0.0
    input name: "devHumCal", type: "decimal", title: "Device humidity calibration (%)",
          range: "-20..20", defaultValue: 0.0
    input name: "devPressCal", type: "decimal", title: "Device pressure calibration (hPa)",
          range: "-400..400", defaultValue: 0.0
    input name: "comfortTMin", type: "decimal", title: "Comfort temp min (°C)",
          range: "-10..60", defaultValue: 19.0
    input name: "comfortTMax", type: "decimal", title: "Comfort temp max (°C)",
          range: "-10..60", defaultValue: 27.0
    input name: "comfortHMin", type: "decimal", title: "Comfort humidity min (%)",
          range: "0..100", defaultValue: 40.0
    input name: "comfortHMax", type: "decimal", title: "Comfort humidity max (%)",
          range: "0..100", defaultValue: 60.0
    input name: "writeDeviceParams", type: "bool",
          title: "Write FC11 settings on Configure",
          description: "Leave OFF unless you intend to push values to the device",
          defaultValue: false

    input name: "logEnable", type: "bool", title: "Debug logging", defaultValue: false
    input name: "txtEnable", type: "bool", title: "Description text logging", defaultValue: true
}

}

def installed() { initialize() }

def updated() {
unschedule()
initialize()
if (logEnable) runIn(1800, "logsOff")
if (state.tempC != null && saneTemp(state.tempC as double)) {
publishTemperature(state.tempC as double)
if (state.humidity != null) publishDewPointAndVpd(state.tempC as double, state.humidity as double)
}
}

def initialize() {
try {
sendEvent(name: "checkInterval", value: 12 * 60 * 60 + 2 * 60, displayed: false,
data: [protocol: "zigbee", hubHardwareId: device?.hub?.hardwareID])
} catch (Exception ignored) { }
sendEvent(name: "healthStatus", value: "online")
}

def configure() {
logInfo "configure() – wake sensor first"
def cmds =
cmds += zigbee.configureReporting(0x0402, 0x0000, DataType.INT16, 10, 3600, 20)
cmds += zigbee.configureReporting(0x0405, 0x0000, DataType.UINT16, 10, 3600, 100)
cmds += zigbee.configureReporting(0x0001, 0x0021, DataType.UINT8, 30, 21600, 1)
cmds += ["zdo bind 0x${device.deviceNetworkId} 0x01 0x01 0x0403 {${device.zigbeeId}} {}", "delay 200"]
cmds += zigbee.configureReporting(0x0403, 0x0000, DataType.INT16, 10, 3600, 10)
if (writeDeviceParams) cmds += writeFc11Preferences()
cmds += readMain()
return cmds
}

def refresh() {
logInfo "refresh()"
return readMain()
}

def ping() { return zigbee.readAttribute(0x0000, 0x0000) }

private List readMain() {
def cmds =
cmds += zigbee.readAttribute(0x0402, 0x0000)
cmds += zigbee.readAttribute(0x0405, 0x0000)
cmds += zigbee.readAttribute(0x0001, 0x0021)
cmds += zigbee.readAttribute(0x0403, 0x0000)
cmds += zigbee.readAttribute(0xFC11, 0x2003)
cmds += zigbee.readAttribute(0xFC11, 0x2004)
cmds += zigbee.readAttribute(0xFC11, 0x2007)
cmds += zigbee.readAttribute(0xFC11, 0x0003)
cmds += zigbee.readAttribute(0xFC11, 0x0004)
cmds += zigbee.readAttribute(0xFC11, 0x0005)
cmds += zigbee.readAttribute(0xFC11, 0x0006)
return cmds
}

private List writeFc11Preferences() {
logInfo "Writing FC11 settings"
def cmds =
// Hubitat wattr payload is little-endian on the wire
cmds += wFc11Int16(0x2003, (int) Math.round(prefD(devTempCal) * 100))
cmds += wFc11Int16(0x2004, (int) Math.round(prefD(devHumCal) * 100))
cmds += wFc11Int16(0x2007, (int) Math.round(prefD(devPressCal) * 100))
cmds += wFc11Int16(0x0003, (int) Math.round(prefD(comfortTMax, 27) * 100))
cmds += wFc11Int16(0x0004, (int) Math.round(prefD(comfortTMin, 19) * 100))
cmds += wFc11UInt16(0x0005, (int) Math.round(prefD(comfortHMin, 40) * 100))
cmds += wFc11UInt16(0x0006, (int) Math.round(prefD(comfortHMax, 60) * 100))
return cmds
}

private List wFc11Int16(int attr, int v) {
v = Math.max(-32768, Math.min(32767, v))
if (v < 0) v += 65536
String le = String.format("%02X%02X", v & 0xFF, (v >> 8) & 0xFF)
return ["he wattr 0x${device.deviceNetworkId} 0x01 0xFC11 0x${String.format('%04X', attr)} 0x29 {${le}}", "delay 300"]
}

private List wFc11UInt16(int attr, int v) {
v = Math.max(0, Math.min(65535, v))
String le = String.format("%02X%02X", v & 0xFF, (v >> 8) & 0xFF)
return ["he wattr 0x${device.deviceNetworkId} 0x01 0xFC11 0x${String.format('%04X', attr)} 0x21 {${le}}", "delay 300"]
}

def parse(String description) {
if (logEnable) log.debug "parse: ${description}"
if (description?.startsWith("catchall:")) return

def descMap
try {
    descMap = zigbee.parseDescriptionAsMap(description)
} catch (Exception e) {
    return
}
if (!descMap?.value || descMap.clusterInt == null || descMap.attrInt == null) return

sendEvent(name: "lastCheckin", value: new Date().format("yyyy-MM-dd HH:mm:ss"), displayed: false)
sendEvent(name: "healthStatus", value: "online")

int cluster = descMap.clusterInt as int
int attr = descMap.attrInt as int
// Logical hex from Hubitat (already endian-corrected). Multi-attr blobs: take leading bytes only.
String hex = (descMap.value as String).replaceAll("[^0-9A-Fa-f]", "")

// Temperature 0x0402 / 0x0000 — INT16, °C × 100
if (cluster == 0x0402 && attr == 0x0000) {
    int raw = parseInt16(hex)
    double c = raw / 100.0d + prefD(tempOffset)
    if (!saneTemp(c)) {
        if (logEnable) log.warn "Temp out of range value=${hex} -> ${c}°C (ignored)"
        return
    }
    state.tempC = c
    publishTemperature(c)
    if (state.humidity != null) publishDewPointAndVpd(c, state.humidity as double)
    return
}

// Humidity 0x0405 / 0x0000 — UINT16, % × 100
if (cluster == 0x0405 && attr == 0x0000) {
    int raw = parseUInt16(hex)
    double h = raw / 100.0d + prefD(humOffset)
    h = Math.round(h * 10.0d) / 10.0d
    if (h < 0.0d || h > 100.0d) {
        if (logEnable) log.warn "Humidity out of range value=${hex} -> ${h}% (ignored)"
        return
    }
    state.humidity = h
    sendEvent(name: "humidity", value: h, unit: "%")
    if (txtEnable) log.info "Humidity: ${h}%"
    if (state.tempC != null) publishDewPointAndVpd(state.tempC as double, h)
    return
}

// Pressure 0x0403 / 0x0000 — INT16, 0.1 kPa (numeric hPa)
if (cluster == 0x0403 && attr == 0x0000) {
    int raw = parseInt16(hex)
    double p = raw + prefD(pressOffset)
    p = Math.round(p * 10.0d) / 10.0d
    if (p < 300.0d || p > 1100.0d) {
        if (logEnable) log.warn "Pressure out of range value=${hex} -> ${p} hPa (ignored)"
        return
    }
    sendEvent(name: "pressure", value: p, unit: "hPa")
    if (txtEnable) log.info "Pressure: ${p} hPa"
    return
}

// Pressure manufacturer 0x0403 / 0x0004 — INT32 × 100
if (cluster == 0x0403 && attr == 0x0004) {
    long raw = parseInt32(hex)
    double p = raw / 100.0d + prefD(pressOffset)
    p = Math.round(p * 10.0d) / 10.0d
    if (p < 300.0d || p > 1100.0d) {
        if (logEnable) log.warn "Pressure(mfg) out of range value=${hex} -> ${p} hPa (ignored)"
        return
    }
    sendEvent(name: "pressure", value: p, unit: "hPa")
    if (txtEnable) log.info "Pressure: ${p} hPa"
    return
}

// Battery 0x0001 / 0x0021
if (cluster == 0x0001 && attr == 0x0021) {
    int raw = Integer.parseInt(hex.length() >= 2 ? hex.substring(0, 2) : hex, 16)
    int bat = (raw > 100) ? (int) Math.round(raw / 2.0d) : raw
    bat = Math.max(0, Math.min(100, bat))
    sendEvent(name: "battery", value: bat, unit: "%")
    if (txtEnable) log.info "Battery: ${bat}%"
    return
}

// FC11
if (cluster == 0xFC11) {
    switch (attr) {
        case 0x2003:
            double v = parseInt16(hex) / 100.0d
            sendEvent(name: "temperatureCalibration", value: v, unit: "°C")
            if (txtEnable) log.info "Device temp calibration: ${v}°C"
            break
        case 0x2004:
            double v = parseInt16(hex) / 100.0d
            sendEvent(name: "humidityCalibration", value: v, unit: "%")
            if (txtEnable) log.info "Device humidity calibration: ${v}%"
            break
        case 0x2007:
            double v = parseInt16(hex) / 100.0d
            sendEvent(name: "pressureCalibration", value: v, unit: "hPa")
            if (txtEnable) log.info "Device pressure calibration: ${v} hPa"
            break
        case 0x0003:
            double v = parseInt16(hex) / 100.0d
            if (v >= -20 && v <= 80) {
                sendEvent(name: "comfortTempMax", value: v, unit: "°C")
                if (txtEnable) log.info "Comfort temp max: ${v}°C"
            }
            break
        case 0x0004:
            double v = parseInt16(hex) / 100.0d
            if (v >= -20 && v <= 80) {
                sendEvent(name: "comfortTempMin", value: v, unit: "°C")
                if (txtEnable) log.info "Comfort temp min: ${v}°C"
            }
            break
        case 0x0005:
            double v = parseUInt16(hex) / 100.0d
            if (v >= 0 && v <= 100) {
                sendEvent(name: "comfortHumidityMin", value: v, unit: "%")
                if (txtEnable) log.info "Comfort humidity min: ${v}%"
            }
            break
        case 0x0006:
            double v = parseUInt16(hex) / 100.0d
            if (v >= 0 && v <= 100) {
                sendEvent(name: "comfortHumidityMax", value: v, unit: "%")
                if (txtEnable) log.info "Comfort humidity max: ${v}%"
            }
            break
    }
    return
}

}

// ---- Logical (Hubitat) multi-byte parse — no endian swap ----
private int parseUInt16(String hex) {
String h = hex.padLeft(4, "0").substring(0, 4)
return Integer.parseInt(h, 16) & 0xFFFF
}

private int parseInt16(String hex) {
int v = parseUInt16(hex)
return (v > 32767) ? (v - 65536) : v
}

private long parseInt32(String hex) {
String h = hex.padLeft(8, "0").substring(0, 8)
long v = Long.parseLong(h, 16)
if (v > 0x7FFFFFFFL) v -= 0x100000000L
return v
}

private boolean saneTemp(double c) { return c >= -40.0d && c <= 85.0d }

private void publishTemperature(double tempC) {
String u = (settings?.tempUnit ?: "C")
double t = (u == "F") ? (tempC * 1.8d + 32.0d) : tempC
t = Math.round(t * 10.0d) / 10.0d
String unit = (u == "F") ? "°F" : "°C"
sendEvent(name: "temperature", value: t, unit: unit)
if (txtEnable) log.info "Temperature: ${t}${unit}"
}

private void publishDewPointAndVpd(double tempC, double humidity) {
if (!saneTemp(tempC)) return
if (humidity < 0.0d || humidity > 100.0d) return

double svp = 0.61078d * Math.exp((17.27d * tempC) / (tempC + 237.3d))
double avp = (humidity / 100.0d) * svp
double vpd = Math.round((svp - avp) * 100.0d) / 100.0d
sendEvent(name: "vpd", value: vpd, unit: "kPa")

if (humidity <= 0.0d) return
double alpha = (17.27d * tempC) / (tempC + 237.7d) + Math.log(humidity / 100.0d)
double denom = 17.27d - alpha
if (Math.abs(denom) < 1e-6d) return
double dewC = (237.7d * alpha) / denom
if (!saneTemp(dewC)) return
dewC = Math.round(dewC * 10.0d) / 10.0d

String u = (settings?.tempUnit ?: "C")
double dew = (u == "F") ? (dewC * 1.8d + 32.0d) : dewC
dew = Math.round(dew * 10.0d) / 10.0d
String unit = (u == "F") ? "°F" : "°C"
sendEvent(name: "dewPoint", value: dew, unit: unit)
if (txtEnable) log.info "Dew point: ${dew}${unit}, VPD: ${vpd} kPa"

}

private double prefD(def p, double fb = 0.0d) {
if (p == null) return fb
try { return p as double } catch (Exception e) { return fb }
}

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

private void logInfo(String msg) {
if (txtEnable) log.info "${device.displayName} ${msg}"
}

What specific data did you give the AI and what specific question did you ask the AI in order for it to be able to do this?

The model number and brand.

It gave back code that I tested, and if it didn't work, I sent back the logs, until it did work.

usually works best if you give Grok the device fingerprint, using the "Device" driver and get info button

Grok figured it out, so I didn't have to do that.