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. ![]()
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}"
}
}
}
