Tuya Zigbee Motion Sensor with Illuminance Driver for ZG-204Z

I bought one of these on Ali with a recent order, just out of curiosity since they are cheap.

Then I went to @kkossev's GitHub to grab a driver for it... and there was not one for a Tuya combo PIR sensor with LUX. :open_mouth:

So I put AI on the job, and it came up with a working driver. Just FYI if anyone needs a cheap Zigbee Motion + Lux device.

/*
 * Tuya ZigBee ZG-204Z Motion & Illuminance Sensor
 * Integer illuminance with selectable scaling
 */

metadata {
    definition (
        name: "Tuya ZG-204Z Motion Sensor",
        namespace: "user",
        author: "Hubitat Community"
    ) {
        capability "Motion Sensor"
        capability "Illuminance Measurement"
        capability "Battery"
        capability "Configuration"
        capability "Refresh"
        capability "Health Check"

        command "enrollResponse"
        command "clearZoneStatus"

        fingerprint endpointId: "01", profileId: "0104", inClusters: "0000,0003,0500,EF00,0001,0400", model: "ZG-204Z", manufacturer: "HOBEIAN"
        fingerprint endpointId: "01", profileId: "0104", inClusters: "0000,0003,0500,EF00,0001,0400", model: "ZG-204ZL", manufacturer: "HOBEIAN"

        attribute "motion", "enum", ["inactive", "active"]
        attribute "illuminance", "number"
        attribute "battery", "number"
        attribute "zoneStatus", "number"
    }

    preferences {
        input name: "logEnable", type: "bool", title: "Enable debug logging", defaultValue: true
        input name: "txtEnable", type: "bool", title: "Enable descriptionText logging", defaultValue: true
        input name: "illuminanceScale", type: "enum", title: "Illuminance Scaling", defaultValue: "1",
               options: [
                   "0.01": "0.01 lux (Zigbee standard /100)",
                   "1": "1 lux (raw integer)",
                   "10": "10 lux (raw ×10 for high range)"
               ]
        input name: "batteryReportingMin", type: "number", title: "Battery Min Report Interval (seconds)", range: "30..86400", defaultValue: 3600
        input name: "batteryReportingMax", type: "number", title: "Battery Max Report Interval (seconds)", range: "30..86400", defaultValue: 43200
        input name: "illuminanceReportingMin", type: "number", title: "Illuminance Min Report Interval (seconds)", range: "1..86400", defaultValue: 30
        input name: "illuminanceReportingMax", type: "number", title: "Illuminance Max Report Interval (seconds)", range: "1..86400", defaultValue: 3600
    }
}

def installed() {
    log("installed...", "info", true)
    if (txtEnable) descriptionText("installed")
    configure()
}

def updated() {
    log("updated...", "info", true)
    unschedule()
    if (txtEnable) descriptionText("updated")
    if (logEnable) runIn(1800, logsOff)
    configure()
}

def configure() {
    log("Configuring Reporting...", "info", true)

    Integer illMin = illuminanceReportingMin?.toInteger() ?: 30
    Integer illMax = illuminanceReportingMax?.toInteger() ?: 3600
    Integer batMin = batteryReportingMin?.toInteger() ?: 3600
    Integer batMax = batteryReportingMax?.toInteger() ?: 43200

    def cmds = []
    cmds += zigbee.configureReporting(0x0400, 0x0000, 0x21, illMin, illMax, 1)   // illuminance
    cmds += zigbee.configureReporting(0x0001, 0x0021, 0x20, batMin, batMax, 5)    // battery
    cmds += zigbee.configureReporting(0x0500, 0x0002, 0x19, 0, 3600, 1)           // zone status
    cmds += refresh()

    return cmds.flatten()
}

def refresh() {
    log("Refresh...", "debug")
    [
        zigbee.readAttribute(0x0400, 0x0000),
        zigbee.readAttribute(0x0001, 0x0021)
    ]
}

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

def clearZoneStatus() {
    log("Clear Zone Status requested", "debug")
    sendZigbeeCommands(zigbee.writeAttribute(0x0500, 0x0000, 0x30, 0x00))
}

def enrollResponse() {
    log("Sending Enroll Response", "debug")
    sendZigbeeCommands(zigbee.enrollResponse())
}

def parse(String description) {
    if (logEnable) log("parse: ${description}", "debug")

    if (description.startsWith("zone status")) {
        return parseIasZoneText(description)
    }

    if (description.startsWith("read attr")) {
        return parseReadAttr(description)
    }

    def result = zigbee.getEvent(description)
    if (result) {
        if (logEnable) log("zigbee.getEvent returned: ${result}", "debug")
        if (result.name == "illuminance") {
            // Re-apply scaling in case getEvent already gave a value
            def scaled = applyIlluminanceScale(result.value)
            sendEvent(name: "illuminance", value: scaled, unit: "lux", descriptionText: "Illuminance is ${scaled} lux")
        }
        return result
    }

    def descMap = zigbee.parseDescriptionAsMap(description)
    if (!descMap) {
        log("Could not parse description: ${description}", "warn")
        return []
    }

    def cluster = descMap.cluster
    switch (cluster) {
        case "0400":
            return handleIlluminanceCluster(descMap)
        case "0500":
            return handleIasZoneCluster(descMap)
        case "0001":
            return handlePowerCluster(descMap)
        default:
            log("Unhandled cluster: ${cluster}", "debug")
            return []
    }
}

private parseIasZoneText(String description) {
    def pattern = /zone status (0x[0-9a-fA-F]+)/
    def matcher = (description =~ pattern)
    if (matcher.find()) {
        def statusHex = matcher.group(1)
        def statusInt = Integer.parseInt(statusHex.substring(2), 16)
        sendEvent(name: "zoneStatus", value: statusInt, displayed: false)

        def isActive = (statusInt & 1) == 1
        def motionValue = isActive ? "active" : "inactive"
        descriptionText("Motion is ${motionValue} (zoneStatus: ${statusInt})")
        return createEvent(name: "motion", value: motionValue)
    } else {
        log("Could not parse zone status from: ${description}", "warn")
        return []
    }
}

private parseReadAttr(String description) {
    def clusterPattern = /cluster:\s*([0-9A-Fa-f]+)/
    def attrIdPattern = /attrId:\s*([0-9A-Fa-f]+)/
    def valuePattern = /value:\s*([0-9A-Fa-f]+)/

    def clusterMatch = (description =~ clusterPattern)
    def attrMatch = (description =~ attrIdPattern)
    def valueMatch = (description =~ valuePattern)

    if (clusterMatch.find() && attrMatch.find() && valueMatch.find()) {
        def cluster = clusterMatch.group(1)
        def attrId = attrMatch.group(1)
        def hexValue = valueMatch.group(1)

        if (cluster == "0400" && attrId == "0000") {
            def rawInt = Integer.parseInt(hexValue, 16)
            def lux = applyIlluminanceScale(rawInt)
            descriptionText("Illuminance is ${lux} lux")
            return createEvent(name: "illuminance", value: lux, unit: "lux")
        }
        else if (cluster == "0001" && attrId == "0021") {
            def raw = Integer.parseInt(hexValue, 16)
            def pct = Math.min(100, Math.round(raw / 2))
            descriptionText("Battery level is ${pct}%")
            return createEvent(name: "battery", value: pct, unit: "%")
        }
    }
    log("Could not parse read attr: ${description}", "warn")
    return []
}

// Apply user-selected scaling to raw illuminance value
private applyIlluminanceScale(rawValue) {
    if (rawValue == null) return 0
    def scale = illuminanceScale ?: "1"
    def lux
    switch (scale) {
        case "0.01":
            lux = rawValue / 100   // Zigbee standard (0.01 lux units)
            break
        case "10":
            lux = rawValue * 10
            break
        default:   // "1"
            lux = rawValue
    }
    // Return as integer (round for fractional scales)
    return Math.round(lux)
}

private handleIlluminanceCluster(descMap) {
    def value = decodeIlluminance(descMap)
    if (value == null) return []
    def lux = applyIlluminanceScale(value)
    descriptionText("Illuminance is ${lux} lux")
    return createEvent(name: "illuminance", value: lux, unit: "lux")
}

private handleIasZoneCluster(descMap) {
    if (descMap.command == "00") {
        def zoneStatus = descMap.value ?: descMap.data
        if (zoneStatus == null) return []
        def statusInt = convertToInteger(zoneStatus)
        if (statusInt == null) return []

        sendEvent(name: "zoneStatus", value: statusInt, displayed: false)
        def isActive = (statusInt & 1) == 1
        def motionValue = isActive ? "active" : "inactive"
        descriptionText("Motion is ${motionValue} (zoneStatus: ${statusInt})")
        return createEvent(name: "motion", value: motionValue)
    }
    return []
}

private handlePowerCluster(descMap) {
    def attr = descMap.attrId ?: (descMap.data ? descMap.data.split(",")[0] : null)
    def value = descMap.value ?: (descMap.data ? descMap.data.split(",")[1] : null)

    if (attr == null || value == null) return []

    switch (attr) {
        case "20":
        case "0020":
            def volts = convertToInteger(value)
            if (volts != null) {
                def batteryPercent = (volts - 24) * 100 / 6
                batteryPercent = Math.min(100, Math.max(0, Math.round(batteryPercent)))
                descriptionText("Battery level is ${batteryPercent}% (${volts}V)")
                return createEvent(name: "battery", value: batteryPercent, unit: "%")
            }
            break
        case "21":
        case "0021":
            def raw = convertToInteger(value)
            if (raw != null) {
                def pct = Math.min(100, Math.round(raw / 2))
                descriptionText("Battery level is ${pct}% (raw: ${raw})")
                return createEvent(name: "battery", value: pct, unit: "%")
            }
            break
    }
    return []
}

private decodeIlluminance(descMap) {
    def rawValue = null
    if (descMap.data) {
        rawValue = descMap.data.split(",")[0]
    } else if (descMap.value) {
        rawValue = descMap.value
    }

    if (rawValue == null) return null

    def rawInt = convertToInteger(rawValue)
    if (rawInt == null) return null
    return rawInt
}

private convertToInteger(val) {
    if (val == null) return null
    if (val instanceof Integer) return val
    if (val instanceof Long) return val.intValue()
    if (val instanceof String) {
        try {
            if (val.startsWith("0x")) {
                return Integer.parseInt(val.substring(2), 16)
            } else {
                return Integer.parseInt(val)
            }
        } catch (NumberFormatException ignore) {
            return null
        }
    }
    return null
}

private sendZigbeeCommands(cmds) {
    if (!cmds) return
    def cmdList = cmds instanceof List ? cmds : [cmds]
    cmdList.each { cmd ->
        if (logEnable) log("Sending command: ${cmd}", "debug")
        sendHubCommand(new hubitat.device.HubAction(cmd, hubitat.device.Protocol.ZIGBEE))
    }
}

private descriptionText(message) {
    if (txtEnable) log.info(message)
}

private log(message, level = "debug", force = false) {
    if (force || logEnable) {
        switch (level) {
            case "info":
                log.info "${device.displayName} ${message}"
                break
            case "warn":
                log.warn "${device.displayName} ${message}"
                break
            default:
                log.debug "${device.displayName} ${message}"
        }
    }
}