I spent a half-hour or so with ChatGPT (it took 4 or 5 passes) to come up with the attached driver. The light functions seem to work well, illuminance responds to light changes, I think motion is working OK, and I have no clue as to what the TVOC number indicates in terms of real world significance.
EDIT: Bug fixed in the attached version re reporting Lux (log spamming).
- added an illuminance deadband preference, default 3 Lux
- added a minimum seconds between small illuminance reports preference, default 30 seconds
- changed the illuminance handling so 1-lux chatter is suppressed unless enough time has passed
- fixed a second source of spam: the driver was writing
infolog lines before checking whether the event was actually changing - made
configure()request less chatty illuminance reporting too
EDIT 2: Revised driver that handles TVOC values better and reduces TVOC spam.
EDIT 2026-03-24: I revised the driver to include additional Preference controls. NOTE: for Hubitat Beta testers, there is now an official driver for this device as of 2026-03-24. However, the version below has additional controls and lacks the "Flash" command which does nothing on my system.
EDIT 2026-04-01: An AQI value of "0" is now characterized as "possible error".
BEST PRACTICE: When switching between this driver and the offiicial R3 driver, first set TYPE to "DEVICE" and clear out all prior states.
/**
* THIRDREALITY Smart Presence Sensor R3 (3RPL01084Z)
*
* Hubitat driver that combines:
* - The device's RGB light can be controlled via the standard ColorControl and Switch/Level capabilities.
* - mmWave occupancy/presence reporting (as MotionSensor + custom occupancy attribute)
* - illuminance reporting
* - Air Quality Index reporting from cluster 0x042E
* - Air Quality status derived from the device instruction-sheet thresholds
* - Air Quality Index deadband / minimum report interval filtering
* - Optional driver-side adjustable motion/presence clear timeout
* - Air Quality Index is treated as a whole-number value, since observed reports appear integral
*
* Notes:
* - The presence side is exposed as MotionSensor because Hubitat apps and RM generally
* work better with active/inactive than with PresenceSensor present/not present.
* - Air Quality cluster 0x042E is decoded as a float/integer value and is now reported directly
* using the sensor's own threshold scheme from the printed instruction sheet:
* 1-500 good, 501-1000 ventilate, 1001-3000 warning, >3000 danger.
* 0 = "possible error" since the sheet specifies the device reports 1-500 for good air quality,
* but it's unknown if 0 is possible or what it might mean.
*/
import groovy.transform.Field
@Field static final Integer CLUSTER_ON_OFF = 0x0006
@Field static final Integer CLUSTER_LEVEL = 0x0008
@Field static final Integer CLUSTER_COLOR = 0x0300
@Field static final Integer CLUSTER_ILLUMINANCE = 0x0400
@Field static final Integer CLUSTER_OCCUPANCY = 0x0406
@Field static final Integer CLUSTER_TVOC = 0x042E
metadata {
definition(name: "THIRDREALITY Smart Presence Sensor R3", namespace: "openai v0.16", author: "OpenAI") {
capability "Actuator"
capability "Sensor"
capability "Light"
capability "Switch"
capability "Switch Level"
capability "ChangeLevel"
capability "ColorControl"
capability "MotionSensor"
capability "IlluminanceMeasurement"
capability "Refresh"
capability "Configuration"
attribute "occupancy", "enum", ["occupied", "clear"]
attribute "AirQualityIndex", "number"
attribute "AirQuality", "enum", ["good", "ventilate", "warning", "danger"]
attribute "colorName", "string"
fingerprint profileId: "0104", endpointId: "01",
inClusters: "0000,0003,0004,0005,0006,0008,0012,0300,0400,0406,042E,1000",
outClusters: "0019",
manufacturer: "Third Reality, Inc",
model: "3RPL01084Z",
deviceJoinName: "THIRDREALITY Smart Presence Sensor R3"
}
preferences {
input name: "levelTransitionTime", type: "enum", title: "Level transition time (default: 1s)",
options: [[500:"500 ms"], [1000:"1 s"], [1500:"1.5 s"], [2000:"2 s"], [5000:"5 s"]], defaultValue: 1000
input name: "startLevelChangeRate", type: "enum", title: "Start level change rate (default: Fast)",
options: [[25:"Slow"], [50:"Medium"], [100:"Fast"]], defaultValue: 100
input name: "onTransitionTime", type: "enum", title: "On transition time (default: 1s)",
options: [[500:"500 ms"], [1000:"1 s"], [1500:"1.5 s"], [2000:"2 s"], [5000:"5 s"]], defaultValue: 1000
input name: "offTransitionTime", type: "enum", title: "Off transition time (default: 1s)",
options: [[500:"500 ms"], [1000:"1 s"], [1500:"1.5 s"], [2000:"2 s"], [5000:"5 s"]], defaultValue: 1000
input name: "rgbTransitionTime", type: "enum", title: "RGB transition time (default: 1s)",
options: [[500:"500 ms"], [1000:"1 s"], [1500:"1.5 s"], [2000:"2 s"], [5000:"5 s"]], defaultValue: 1000
input name: "minimumLevel", type: "number", title: "Minimum level (default: 5%)",
description: "Requested levels above 0% but below this minimum are raised to this value", defaultValue: 5, range: "0..100"
input name: "colorStaging", type: "bool", title: "Enable color pre-staging when light is off", defaultValue: false
input name: "hiRezHue", type: "bool", title: "Use hue in degrees (0-360) instead of percent", defaultValue: false
input name: "autoRefreshMinutes", type: "enum", title: "Automatic refresh interval",
options: [[0:"Disabled"], [1:"Every 1 minute"], [5:"Every 5 minutes"], [10:"Every 10 minutes"], [15:"Every 15 minutes"], [30:"Every 30 minutes"]], defaultValue: 0
input name: "illuminanceMinDeltaLux", type: "number", title: "Illuminance deadband (Lux)",
description: "Ignore smaller lux changes than this amount", defaultValue: 3, range: "0..1000"
input name: "illuminanceMinSeconds", type: "number", title: "Minimum seconds between small illuminance reports",
description: "Changes smaller than the deadband are ignored until this much time has passed", defaultValue: 30, range: "0..3600"
input name: "tvocMinDelta", type: "number", title: "Air Quality Index deadband",
description: "Ignore Air Quality Index changes smaller than this amount unless the Air Quality status band changes", defaultValue: 2, range: "0..10000"
input name: "tvocMinSeconds", type: "number", title: "Minimum seconds between Air Quality Index reports",
description: "Do not send changed Air Quality Index values more often than this, unless the Air Quality status band changes", defaultValue: 30, range: "0..3600"
input name: "motionClearSeconds", type: "number", title: "Driver motion/presence clear timeout (seconds)",
description: "0 = follow device clear reports only. Any value > 0 makes the driver clear motion/occupancy this many seconds after the last occupied report.", defaultValue: 0, range: "0..3600"
input name: "logEnable", type: "bool", title: "Enable debug logging", defaultValue: true
input name: "txtEnable", type: "bool", title: "Enable descriptionText logging", defaultValue: true
}
}
def installed() {
log.info "installed..."
sendEvent(name: "motion", value: "inactive")
sendEvent(name: "occupancy", value: "clear")
unschedule("syntheticMotionClear")
scheduleAutoRefresh()
}
def updated() {
log.info "updated..."
log.warn "debug logging is: ${logEnable == true}"
log.warn "description logging is: ${txtEnable == true}"
log.warn "driver motion/presence clear timeout is: ${safeToInt(settings.motionClearSeconds, 0)} second(s)"
if (logEnable) runIn(1800, "logsOff")
if (safeToInt(settings.motionClearSeconds, 0) <= 0) unschedule("syntheticMotionClear")
scheduleAutoRefresh()
}
def logsOff() {
log.warn "debug logging disabled..."
device.updateSetting("logEnable", [value: "false", type: "bool"])
}
private void scheduleAutoRefresh() {
unschedule("refresh")
Integer mins = safeToInt(settings.autoRefreshMinutes, 0)
switch (mins) {
case 1:
runEvery1Minute("refresh")
break
case 5:
runEvery5Minutes("refresh")
break
case 10:
runEvery10Minutes("refresh")
break
case 15:
runEvery15Minutes("refresh")
break
case 30:
runEvery30Minutes("refresh")
break
default:
break
}
}
def parse(String description) {
if (logEnable) log.debug "parse description: ${description}"
if (!description || description.startsWith("catchall")) return
Map descMap = zigbee.parseDescriptionAsMap(description)
if (logEnable) log.debug "descMap: ${descMap}"
if (!descMap?.clusterInt) return
String descriptionText
String name
Object value
String unit
switch (descMap.clusterInt as Integer) {
case CLUSTER_ON_OFF:
if (descMap.attrInt == 0 && descMap.value != null) {
value = hexToInt(descMap.value, 0) == 1 ? "on" : "off"
name = "switch"
descriptionText = (device.currentValue(name) == value) ?
"${device.displayName} is ${value}" :
"${device.displayName} was turned ${value}"
}
break
case CLUSTER_LEVEL:
if (descMap.attrInt == 0 && descMap.value != null) {
value = Math.round(hexToInt(descMap.value, 0) * 100 / 254.0)
if ((value as Integer) > 0) state.lastNonZeroLevel = (value as Integer)
unit = "%"
name = "level"
Integer current = safeToInt(device.currentValue(name), -1)
descriptionText = (current == (value as Integer)) ?
"${device.displayName} is ${value}${unit}" :
"${device.displayName} was set to ${value}${unit}"
}
break
case CLUSTER_COLOR:
return parseColorCluster(descMap)
case CLUSTER_ILLUMINANCE:
if (descMap.attrInt == 0 && descMap.value != null) {
Integer raw = hexToInt(descMap.value, 0)
Integer lux = raw > 0 ? Math.round(Math.pow(10, ((raw - 1) / 10000.0d))) : 0
handleIlluminanceReport(lux)
}
return
case CLUSTER_OCCUPANCY:
if (descMap.attrInt == 0 && descMap.value != null) {
Integer raw = hexToInt(descMap.value, 0)
Boolean occupied = (raw & 0x01) == 0x01
handleOccupancyReport(occupied)
}
return
case CLUSTER_TVOC:
if (descMap.attrInt == 0 && descMap.value != null) {
BigDecimal tvocValue = parseTvocValue(descMap)
String rawString = "enc=${descMap.encoding ?: '??'} hex=${descMap.value}"
if (logEnable) log.debug buildTvocDebugLine(descMap, tvocValue)
if (tvocValue != null) {
Integer roundedValue = tvocValue.setScale(0, BigDecimal.ROUND_HALF_UP).toInteger()
handleTvocReport(roundedValue)
} else if (logEnable) {
log.debug "Unable to parse Air Quality Index value from ${rawString}"
}
}
break
default:
if (logEnable) log.debug "ignoring ${descMap.clusterId}:${descMap.attrId}"
return
}
if (descriptionText && name) {
sendEventIfChanged(name, value, descriptionText, unit)
}
}
private Object parseColorCluster(Map descMap) {
String descriptionText
String name
Object value
String unit
Integer rawValue = hexToInt(descMap.value, 0)
switch (descMap.attrInt as Integer) {
case 0: // hue
if (hiRezHue) {
value = Math.round(rawValue * 360 / 254)
if ((value as Integer) == 361) value = 360
unit = "°"
} else {
value = Math.round(rawValue / 254 * 100)
unit = "%"
}
name = "hue"
state.lastHue = descMap.value
descriptionText = (safeToInt(device.currentValue(name), -999) == (value as Integer)) ?
"${device.displayName} ${name} is ${value}${unit}" :
"${device.displayName} ${name} was set to ${value}${unit}"
break
case 1: // saturation
value = Math.round(rawValue / 254 * 100)
unit = "%"
name = "saturation"
state.lastSaturation = descMap.value
descriptionText = (safeToInt(device.currentValue(name), -999) == (value as Integer)) ?
"${device.displayName} ${name} is ${value}${unit}" :
"${device.displayName} ${name} was set to ${value}${unit}"
break
case 8: // color mode
value = rawValue == 2 ? "CT" : "RGB"
name = "colorMode"
descriptionText = "${device.displayName} ${name} is ${value}"
break
default:
if (logEnable) log.debug "0x0300:${descMap.attrId}:${rawValue}"
return
}
sendEventIfChanged(name, value, descriptionText, unit)
if (name in ["hue", "saturation"]) {
setGenericColorName()
}
}
private void handleOccupancyReport(Boolean occupied) {
Integer clearSeconds = Math.max(0, safeToInt(settings.motionClearSeconds, 0))
if (occupied) {
sendEventIfChanged("motion", "active", "${device.displayName} motion is active", null)
sendEventIfChanged("occupancy", "occupied", "${device.displayName} occupancy is occupied", null)
state.lastOccupiedReportMs = now()
if (clearSeconds > 0) {
runIn(clearSeconds, "syntheticMotionClear")
if (logEnable) log.debug "Scheduled synthetic motion clear in ${clearSeconds} second(s)"
}
} else {
unschedule("syntheticMotionClear")
sendEventIfChanged("motion", "inactive", "${device.displayName} motion is inactive", null)
sendEventIfChanged("occupancy", "clear", "${device.displayName} occupancy is clear", null)
}
}
def syntheticMotionClear() {
Integer clearSeconds = Math.max(0, safeToInt(settings.motionClearSeconds, 0))
if (clearSeconds <= 0) return
Long lastOccupiedMs = state.lastOccupiedReportMs != null ? (state.lastOccupiedReportMs as Long) : 0L
Long elapsedMs = lastOccupiedMs > 0L ? (now() - lastOccupiedMs) : Long.MAX_VALUE
Long targetMs = clearSeconds * 1000L
if (elapsedMs < targetMs) {
Integer remainingSeconds = Math.max(1, Math.ceil((targetMs - elapsedMs) / 1000.0d) as Integer)
runIn(remainingSeconds, "syntheticMotionClear")
return
}
String motionCurrent = device.currentValue("motion")?.toString()
String occupancyCurrent = device.currentValue("occupancy")?.toString()
if (motionCurrent != "inactive" || occupancyCurrent != "clear") {
sendEventIfChanged("motion", "inactive", "${device.displayName} motion is inactive", null)
sendEventIfChanged("occupancy", "clear", "${device.displayName} occupancy is clear", null)
if (txtEnable) log.info "${device.displayName} motion/presence was cleared by driver timeout (${clearSeconds}s)"
}
}
private void handleTvocReport(Integer tvoc) {
Integer minDelta = Math.max(0, safeToInt(settings.tvocMinDelta, 2))
Integer minSeconds = Math.max(0, safeToInt(settings.tvocMinSeconds, 30))
Long nowMs = now()
Integer lastReportedTvoc = state.lastReportedTvoc != null ? safeToInt(state.lastReportedTvoc, tvoc) : null
Long lastReportMs = state.lastTvocReportMs != null ? (state.lastTvocReportMs as Long) : 0L
String status = classifyTvoc(tvoc)
String currentStatus = device.currentValue("AirQuality")?.toString()
Boolean shouldSendValue = false
if (lastReportedTvoc == null) {
shouldSendValue = true
} else if (currentStatus != status) {
shouldSendValue = true
} else {
Integer delta = Math.abs(tvoc - lastReportedTvoc)
Long elapsedMs = nowMs - lastReportMs
if (delta >= minDelta && elapsedMs >= (minSeconds * 1000L)) {
shouldSendValue = true
}
}
sendEventIfChanged("AirQuality", status, "${device.displayName} Air Quality is ${status}", null)
if (shouldSendValue) {
state.lastReportedTvoc = tvoc
state.lastTvocReportMs = nowMs
sendEventIfChanged("AirQualityIndex", tvoc, "${device.displayName} Air Quality Index is ${tvoc} (${status})", null)
} else if (logEnable) {
log.debug "Filtered Air Quality Index report: ${tvoc} (${status})"
}
}
private void handleIlluminanceReport(Integer lux) {
Integer minDelta = Math.max(0, safeToInt(settings.illuminanceMinDeltaLux, 3))
Integer minSeconds = Math.max(0, safeToInt(settings.illuminanceMinSeconds, 30))
Long nowMs = now()
Integer lastReportedLux = state.lastReportedIlluminanceLux != null ? safeToInt(state.lastReportedIlluminanceLux, lux) : null
Long lastReportMs = state.lastIlluminanceReportMs != null ? (state.lastIlluminanceReportMs as Long) : 0L
Boolean shouldSend = false
if (lastReportedLux == null) {
shouldSend = true
} else {
Integer delta = Math.abs(lux - lastReportedLux)
Long elapsedMs = nowMs - lastReportMs
if (delta >= minDelta) {
shouldSend = true
} else if (lux != lastReportedLux && elapsedMs >= (minSeconds * 1000L)) {
shouldSend = true
}
}
if (shouldSend) {
state.lastReportedIlluminanceLux = lux
state.lastIlluminanceReportMs = nowMs
sendEventIfChanged("illuminance", lux, "${device.displayName} illuminance is ${lux} Lux", "Lux")
} else if (logEnable) {
log.debug "Filtered illuminance report: ${lux} Lux"
}
}
private String buildTvocDebugLine(Map descMap, BigDecimal tvocValue) {
String hex = (descMap?.value ?: "").toString()
Integer encoding = hexToInt(descMap?.encoding, -1)
Float fBig = decodeFloatBigEndian(hex)
Float fLittle = decodeFloatLittleEndian(hex)
Long unsignedVal = null
Long signedVal = null
try {
unsignedVal = parseUnsignedHex(hex)
} catch (ignored) { }
try {
signedVal = parseSignedHex(hex, Math.max(1, (hex?.length() ?: 0) / 2))
} catch (ignored) { }
String status = tvocValue != null ? classifyTvoc(tvocValue.setScale(0, BigDecimal.ROUND_HALF_UP).toInteger()) : null
return "Air Quality Index 0x042E debug: enc=${String.format('0x%02X', encoding)} hex=${hex} unsigned=${unsignedVal} signed=${signedVal} floatBE=${fBig} floatLE=${fLittle} decoded=${tvocValue} status=${status}"
}
private BigDecimal parseTvocValue(Map descMap) {
Integer encoding = hexToInt(descMap.encoding, -1)
String hex = descMap.value ?: ""
if (!hex) return null
switch (encoding) {
case 0x39: // single-precision float; likely ppm
Float f1 = decodeFloatBigEndian(hex)
Float f2 = decodeFloatLittleEndian(hex)
Float chosen = chooseReasonableFloat(f1, f2)
if (chosen == null) return null
return BigDecimal.valueOf(chosen as Double)
case 0x20: // uint8
case 0x21: // uint16
case 0x22: // uint24
case 0x23: // uint32
return BigDecimal.valueOf(parseUnsignedHex(hex))
case 0x28: // int8
case 0x29: // int16
case 0x2A: // int24
case 0x2B: // int32
return BigDecimal.valueOf(parseSignedHex(hex, hex.length() / 2))
default:
// Fall back to unsigned integer interpretation; this matches how many Hubitat descMap values appear.
try {
return BigDecimal.valueOf(parseUnsignedHex(hex))
} catch (ignored) {
return null
}
}
}
private static String classifyTvoc(Integer value) {
if (value == null) return null
if (value == 0) return "possible error"
if (value <= 500) return "good"
if (value <= 1000) return "ventilate"
if (value <= 3000) return "warning"
return "danger"
}
private static Float chooseReasonableFloat(Float a, Float b) {
List<Float> candidates = [a, b].findAll { it != null && !it.isNaN() && !it.isInfinite() && it >= 0.0f && it < 10000.0f }
if (!candidates) return null
// Prefer a practical non-subnormal value; cluster 0x042E reports on this device are arriving as
// IEEE-754 values like 41.0 / 42.0 / 60.0, while the opposite endianness decodes to tiny near-zero noise.
List<Float> practical = candidates.findAll { it >= 0.001f }
if (practical) return practical.max()
return candidates.max()
}
private static Float decodeFloatBigEndian(String hex) {
try {
int bits = (int) Long.parseLong(hex, 16)
return Float.intBitsToFloat(bits)
} catch (ignored) {
return null
}
}
private static Float decodeFloatLittleEndian(String hex) {
try {
int bits = (int) Long.parseLong(hex, 16)
return Float.intBitsToFloat(Integer.reverseBytes(bits))
} catch (ignored) {
return null
}
}
private static Long parseUnsignedHex(String hex) {
return Long.parseLong(hex, 16)
}
private static Long parseSignedHex(String hex, Integer bytes) {
long unsigned = Long.parseLong(hex, 16)
long signBit = 1L << ((bytes * 8) - 1)
long fullRange = 1L << (bytes * 8)
return (unsigned & signBit) ? (unsigned - fullRange) : unsigned
}
private void setGenericColorName() {
Integer hue = safeToInt(device.currentValue("hue"), 0)
Integer sat = safeToInt(device.currentValue("saturation"), 100)
if (!hiRezHue) hue = Math.round(hue * 3.6)
String colorName
switch (hue) {
case 0..15: colorName = "Red"; break
case 16..45: colorName = "Orange"; break
case 46..75: colorName = "Yellow"; break
case 76..105: colorName = "Chartreuse"; break
case 106..135: colorName = "Green"; break
case 136..165: colorName = "Spring"; break
case 166..195: colorName = "Cyan"; break
case 196..225: colorName = "Azure"; break
case 226..255: colorName = "Blue"; break
case 256..285: colorName = "Violet"; break
case 286..315: colorName = "Magenta"; break
case 316..345: colorName = "Rose"; break
default: colorName = "Red"; break
}
if (sat == 0) colorName = "White"
sendEventIfChanged("colorName", colorName, "${device.displayName} color is ${colorName}", null)
}
def on() {
if (logEnable) log.debug "on()"
Integer transitionMs = getConfiguredTransitionMs("onTransitionTime", 1000)
Integer targetLevel = resolveOnLevelPercent()
Integer zigbeeLevel = scalePercentToZigbeeLevel(targetLevel)
Integer transition = transitionMsToTenths(transitionMs)
Integer delayMs = Math.max(400, transitionMs + 400)
return [
"he cmd 0x${device.deviceNetworkId} 0x${device.endpointId} 0x0008 4 {0x${intTo8bitUnsignedHex(zigbeeLevel)} 0x${intTo16bitUnsignedHexLE(transition)}}",
"delay ${delayMs}",
readAttrCmd(CLUSTER_ON_OFF, 0x0000),
"delay 200",
readAttrCmd(CLUSTER_LEVEL, 0x0000)
]
}
def off() {
if (logEnable) log.debug "off()"
Integer transitionMs = getConfiguredTransitionMs("offTransitionTime", 1000)
Integer transition = transitionMsToTenths(transitionMs)
Integer delayMs = Math.max(400, transitionMs + 400)
return [
"he cmd 0x${device.deviceNetworkId} 0x${device.endpointId} 0x0008 4 {0x00 0x${intTo16bitUnsignedHexLE(transition)}}",
"delay ${delayMs}",
readAttrCmd(CLUSTER_ON_OFF, 0x0000),
"delay 200",
readAttrCmd(CLUSTER_LEVEL, 0x0000)
]
}
def startLevelChange(direction) {
if (logEnable) log.debug "startLevelChange(${direction})"
Integer upDown = direction == "down" ? 1 : 0
Integer unitsPerSecond = Math.max(1, safeToInt(settings.startLevelChangeRate, 100))
return "he cmd 0x${device.deviceNetworkId} 0x${device.endpointId} 0x0008 1 { 0x${intTo8bitUnsignedHex(upDown)} 0x${intTo16bitUnsignedHexLE(unitsPerSecond)} }"
}
def stopLevelChange() {
if (logEnable) log.debug "stopLevelChange()"
return [
"he cmd 0x${device.deviceNetworkId} 0x${device.endpointId} 0x0008 3 {}",
"delay 200",
readAttrCmd(CLUSTER_LEVEL, 0x0000)
]
}
def setLevel(value) {
if (logEnable) log.debug "setLevel(${value})"
return setLevel(value, getConfiguredTransitionSeconds("levelTransitionTime", 1.0G))
}
def setLevel(value, rate) {
if (logEnable) log.debug "setLevel(${value}, ${rate})"
Integer requestedLevel = Math.max(0, Math.min(100, safeToInt(value, 0)))
Integer level = clampLevelPercent(requestedLevel)
BigDecimal defaultRateSeconds = getConfiguredTransitionSeconds("levelTransitionTime", 1.0G)
BigDecimal rateSeconds = hasMeaningfulValue(rate) ? safeToBigDecimal(rate, defaultRateSeconds) : defaultRateSeconds
if (rateSeconds <= 0) rateSeconds = defaultRateSeconds > 0 ? defaultRateSeconds : 1.0G
Integer scaledRate = Math.max(1, (rateSeconds * 10).toInteger())
Integer zigbeeLevel = scalePercentToZigbeeLevel(level)
Boolean isOn = device.currentValue("switch") == "on"
Integer delayMs = Math.max(400, (rateSeconds * 1000).toInteger() + 400)
if (level > 0) state.lastNonZeroLevel = level
if (isOn) {
return [
"he cmd 0x${device.deviceNetworkId} 0x${device.endpointId} 0x0008 4 {0x${intTo8bitUnsignedHex(zigbeeLevel)} 0x${intTo16bitUnsignedHexLE(scaledRate)}}",
"delay ${delayMs}",
readAttrCmd(CLUSTER_LEVEL, 0x0000)
]
} else {
return [
"he cmd 0x${device.deviceNetworkId} 0x${device.endpointId} 0x0008 4 {0x${intTo8bitUnsignedHex(zigbeeLevel)} 0x${intTo16bitUnsignedHexLE(scaledRate)}}",
"delay ${delayMs}",
readAttrCmd(CLUSTER_ON_OFF, 0x0000),
"delay 200",
readAttrCmd(CLUSTER_LEVEL, 0x0000)
]
}
}
def setColor(Map value) {
if (logEnable) log.debug "setColor(${value})"
if (value?.hue == null || value?.saturation == null) return
Integer hueInput = safeToInt(value.hue, 0)
Integer satInput = safeToInt(value.saturation, 100)
Integer levelInput = value.level != null ? clampLevelPercent(safeToInt(value.level, 100)) : null
Integer requestedRateSeconds = value.rate != null ? safeToInt(value.rate, 0) : 0
Integer rateMs = requestedRateSeconds > 0 ? (requestedRateSeconds * 1000) : getConfiguredTransitionMs("rgbTransitionTime", 1000)
Boolean isOn = device.currentValue("switch") == "on"
String hexHue = hiRezHue ?
zigbee.convertToHexString(Math.round(hueInput / 360.0 * 254).toInteger(), 2) :
zigbee.convertToHexString(Math.round(hueInput / 100.0 * 254).toInteger(), 2)
String hexSat = zigbee.convertToHexString(Math.round(satInput / 100.0 * 254).toInteger(), 2)
List<String> cmds = []
Integer transition = transitionMsToTenths(rateMs)
Integer zigbeeLevel = levelInput != null ? scalePercentToZigbeeLevel(levelInput) : null
if (levelInput != null && levelInput > 0) state.lastNonZeroLevel = levelInput
if (isOn) {
cmds << "he cmd 0x${device.deviceNetworkId} 0x${device.endpointId} 0x0300 0x06 {${hexHue} ${hexSat} ${intTo16bitUnsignedHexLE(transition)}}"
if (zigbeeLevel != null) {
cmds << "delay 200"
cmds << "he cmd 0x${device.deviceNetworkId} 0x${device.endpointId} 0x0008 4 {0x${intTo8bitUnsignedHex(zigbeeLevel)} 0x${intTo16bitUnsignedHexLE(transition)}}"
cmds << "delay ${rateMs + 400}"
cmds << readAttrCmd(CLUSTER_COLOR, 0x0000)
cmds << "delay 200"
cmds << readAttrCmd(CLUSTER_COLOR, 0x0001)
cmds << "delay 200"
cmds << readAttrCmd(CLUSTER_LEVEL, 0x0000)
} else {
cmds << "delay ${rateMs + 400}"
cmds << readAttrCmd(CLUSTER_COLOR, 0x0000)
cmds << "delay 200"
cmds << readAttrCmd(CLUSTER_COLOR, 0x0001)
}
} else if (colorStaging) {
cmds << "he cmd 0x${device.deviceNetworkId} 0x${device.endpointId} 0x0300 0x06 {${hexHue} ${hexSat} ${intTo16bitUnsignedHexLE(transition)}}"
cmds << "delay ${Math.max(200, rateMs + 200)}"
cmds << readAttrCmd(CLUSTER_COLOR, 0x0000)
cmds << "delay 200"
cmds << readAttrCmd(CLUSTER_COLOR, 0x0001)
} else if (zigbeeLevel != null) {
cmds << "he cmd 0x${device.deviceNetworkId} 0x${device.endpointId} 0x0300 0x06 {${hexHue} ${hexSat} ${intTo16bitUnsignedHexLE(transition)}}"
cmds << "delay 200"
cmds << "he cmd 0x${device.deviceNetworkId} 0x${device.endpointId} 0x0008 4 {0x${intTo8bitUnsignedHex(zigbeeLevel)} 0x${intTo16bitUnsignedHexLE(transition)}}"
cmds << "delay ${rateMs + 400}"
cmds << readAttrCmd(CLUSTER_ON_OFF, 0x0000)
cmds << "delay 200"
cmds << readAttrCmd(CLUSTER_LEVEL, 0x0000)
cmds << "delay 200"
cmds << readAttrCmd(CLUSTER_COLOR, 0x0000)
cmds << "delay 200"
cmds << readAttrCmd(CLUSTER_COLOR, 0x0001)
} else {
cmds << "he cmd 0x${device.deviceNetworkId} 0x${device.endpointId} 0x0300 0x06 {${hexHue} ${hexSat} ${intTo16bitUnsignedHexLE(transition)}}"
cmds << "delay 200"
cmds << on()
cmds = cmds.flatten()
}
state.lastHue = hexHue
state.lastSaturation = hexSat
return cmds
}
def setHue(value) {
if (logEnable) log.debug "setHue(${value})"
setColor([hue: value, saturation: safeToInt(device.currentValue("saturation"), 100), level: safeToInt(device.currentValue("level"), 100)])
}
def setSaturation(value) {
if (logEnable) log.debug "setSaturation(${value})"
setColor([hue: safeToInt(device.currentValue("hue"), 0), saturation: value, level: safeToInt(device.currentValue("level"), 100)])
}
def refresh() {
if (logEnable) log.debug "refresh()"
return [
readAttrCmd(CLUSTER_ON_OFF, 0x0000), "delay 200",
readAttrCmd(CLUSTER_LEVEL, 0x0000), "delay 200",
readAttrCmd(CLUSTER_COLOR, 0x0000), "delay 200",
readAttrCmd(CLUSTER_COLOR, 0x0001), "delay 200",
readAttrCmd(CLUSTER_ILLUMINANCE, 0x0000), "delay 200",
readAttrCmd(CLUSTER_OCCUPANCY, 0x0000), "delay 200",
readAttrCmd(CLUSTER_TVOC, 0x0000)
]
}
def configure() {
log.warn "configure..."
if (logEnable) runIn(1800, "logsOff")
List<String> cmds = []
// Bind standard reporting clusters to the hub.
cmds += [
"zdo bind 0x${device.deviceNetworkId} 0x${device.endpointId} 0x01 0x0006 {${device.zigbeeId}} {}",
"delay 200",
"zdo bind 0x${device.deviceNetworkId} 0x${device.endpointId} 0x01 0x0008 {${device.zigbeeId}} {}",
"delay 200",
"zdo bind 0x${device.deviceNetworkId} 0x${device.endpointId} 0x01 0x0300 {${device.zigbeeId}} {}",
"delay 200",
"zdo bind 0x${device.deviceNetworkId} 0x${device.endpointId} 0x01 0x0400 {${device.zigbeeId}} {}",
"delay 200",
"zdo bind 0x${device.deviceNetworkId} 0x${device.endpointId} 0x01 0x0406 {${device.zigbeeId}} {}",
"delay 200",
"zdo bind 0x${device.deviceNetworkId} 0x${device.endpointId} 0x01 0x042E {${device.zigbeeId}} {}",
"delay 200"
]
// Configure reporting where datatype is known or strongly expected.
cmds += zigbee.configureReporting(CLUSTER_ON_OFF, 0x0000, 0x10, 0, 3600, null)
cmds += zigbee.configureReporting(CLUSTER_LEVEL, 0x0000, 0x20, 1, 3600, 1)
cmds += zigbee.configureReporting(CLUSTER_COLOR, 0x0000, 0x20, 1, 3600, 1)
cmds += zigbee.configureReporting(CLUSTER_COLOR, 0x0001, 0x20, 1, 3600, 1)
cmds += zigbee.configureReporting(CLUSTER_ILLUMINANCE, 0x0000, 0x21, 30, 300, 50)
cmds += zigbee.configureReporting(CLUSTER_OCCUPANCY, 0x0000, 0x18, 0, 3600, 1)
cmds += refresh()
return cmds
}
private Integer getConfiguredTransitionMs(String settingName, Integer defaultMs = 1000) {
Object configured = settings?.get(settingName)
Integer ms = safeToInt(configured, defaultMs)
return ms > 0 ? ms : defaultMs
}
private BigDecimal getConfiguredTransitionSeconds(String settingName, BigDecimal defaultSeconds = 1.0G) {
Integer ms = getConfiguredTransitionMs(settingName, (defaultSeconds * 1000).toInteger())
return BigDecimal.valueOf(ms / 1000.0d)
}
private Integer transitionMsToTenths(Integer ms) {
Integer safeMs = Math.max(0, ms ?: 0)
return Math.max(0, (int) Math.round(safeMs / 100.0d))
}
private Integer getMinimumLevelPercent() {
Integer minLevel = safeToInt(settings.minimumLevel, 5)
return Math.max(0, Math.min(100, minLevel))
}
private Integer clampLevelPercent(Integer level) {
Integer safeLevel = Math.max(0, Math.min(100, level ?: 0))
if (safeLevel == 0) return 0
Integer minLevel = getMinimumLevelPercent()
return Math.max(minLevel, safeLevel)
}
private Integer resolveOnLevelPercent() {
Integer currentLevel = safeToInt(device.currentValue("level"), 0)
Integer rememberedLevel = safeToInt(state.lastNonZeroLevel, 100)
Integer candidate = currentLevel > 0 ? currentLevel : (rememberedLevel > 0 ? rememberedLevel : 100)
return clampLevelPercent(candidate)
}
private Integer scalePercentToZigbeeLevel(Integer levelPercent) {
Integer safeLevel = Math.max(0, Math.min(100, levelPercent ?: 0))
return Math.round(safeLevel * 254 / 100.0d)
}
private Boolean hasMeaningfulValue(Object value) {
if (value == null) return false
if (value instanceof String) return value.toString().trim() != ""
return true
}
private String readAttrCmd(Integer cluster, Integer attrId) {
return "he rattr 0x${device.deviceNetworkId} 0x${device.endpointId} 0x${zigbee.convertToHexString(cluster, 4)} 0x${zigbee.convertToHexString(attrId, 4)} {}"
}
private Boolean sendEventIfChanged(String name, Object value, String descriptionText = null, String unit = null) {
if (name == null) return false
String current = device.currentValue(name)?.toString()
String incoming = value?.toString()
if (current == incoming) return false
Map evt = [name: name, value: value]
if (descriptionText != null) evt.descriptionText = descriptionText
if (unit != null) evt.unit = unit
sendEvent(evt)
if (txtEnable && descriptionText) log.info descriptionText
return true
}
private static Integer hexToInt(Object value, Integer defaultValue = 0) {
try {
if (value == null) return defaultValue
String s = value.toString().trim()
if (!s) return defaultValue
if (s.startsWith("0x") || s.startsWith("0X")) s = s.substring(2)
return Integer.parseInt(s, 16)
} catch (ignored) {
return defaultValue
}
}
private static Integer safeToInt(Object value, Integer defaultValue = 0) {
try {
if (value == null) return defaultValue
if (value instanceof Number) return ((Number) value).intValue()
String s = value.toString().trim()
if (!s) return defaultValue
if (s.startsWith("0x") || s.startsWith("0X")) return Integer.parseInt(s.substring(2), 16)
if (s ==~ /[0-9A-Fa-f]+/ && s.length() > 1 && !(s ==~ /\d+/)) return Integer.parseInt(s, 16)
return Integer.parseInt(s)
} catch (ignored) {
return defaultValue
}
}
private static BigDecimal safeToBigDecimal(Object value, BigDecimal defaultValue = 0G) {
try {
if (value == null) return defaultValue
if (value instanceof BigDecimal) return (BigDecimal) value
if (value instanceof Number) return new BigDecimal(value.toString())
String s = value.toString().trim()
if (!s) return defaultValue
return new BigDecimal(s)
} catch (ignored) {
return defaultValue
}
}
private String intTo8bitUnsignedHex(Object value) {
return zigbee.convertToHexString(safeToInt(value, 0) & 0xFF, 2)
}
private String intTo16bitUnsignedHex(Object value) {
return zigbee.convertToHexString(safeToInt(value, 0) & 0xFFFF, 4)
}
private String intTo16bitUnsignedHexLE(Object value) {
String hex = intTo16bitUnsignedHex(value)
return hex.substring(2, 4) + hex.substring(0, 2)
}