/**
* Tuya Advanced Zigbee RGBW Bulb with healthStatus driver for Hubitat
*
* https://community.hubitat.com/t/tuya-zigbee-bulb/115563/40?u=kkossev
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
* in compliance with the License. You may obtain a copy of the License at:
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
* for the specific language governing permissions and limitations under the License.
*
* This driver is based on @bradsjm code https://github.com/bradsjm/hubitat-public/blob/development/PhilipsHue/Philips-Hue-Zigbee-Bulb-RGBW.groovy
*
* ver. 1.0.0 2023-04-14 kkossev - Initial test version : Hubitat 'F2 bug' workaround; commented out Philips Hue specific commands; trap for Hubitat F2 bug
*
* TODO:
*/
def version() { "1.0.0" }
def timeStamp() {"2023/04/14 12:15 PM"}
@Field static final Boolean _DEBUG = true
import groovy.json.JsonOutput
import groovy.transform.Field
import hubitat.zigbee.zcl.DataType
metadata {
definition(name: 'Tuya Advanced Zigbee RGBW Bulb',
importUrl: 'https://raw.githubusercontent.com/kkossev/Hubitat/development/Drivers/Tuya%20Advanced%20Zigbee%20RGBW%20Bulb/Tuya%20Advanced%20Zigbee%20RGBW%20Bulb.groovy',
namespace: 'kkossev', author: 'Krassimir Kossev') {
capability 'Actuator'
capability 'Bulb'
capability 'Change Level'
capability 'Color Control'
capability 'Color Temperature'
capability 'Color Mode'
capability 'Configuration'
capability 'Flash'
capability 'Health Check'
capability 'Light'
capability 'Level Preset'
//capability 'Light Effects'
capability 'Refresh'
capability 'Switch Level'
capability 'Switch'
//attribute 'effectName', 'string'
attribute 'healthStatus', 'enum', ['unknown', 'offline', 'online']
command 'identify', [[name: 'Effect type*', type: 'ENUM', description: 'Effect Type', constraints: IdentifyEffectNames.values()*.toLowerCase()]]
command 'setColorXy', [
[name: 'X*', type: 'NUMBER', description: 'X value'],
[name: 'Y*', type: 'NUMBER', description: 'Y value'],
[name: 'Level', type: 'NUMBER', description: 'Level to set'],
[name: 'Transition time', type: 'NUMBER', description: 'Transition duration in seconds']
]
command 'setEnhancedHue', [[name: 'Hue*', type: 'NUMBER', description: 'Color Hue (0-360)']]
//command 'setScene', [[name: 'Scene name*', type: 'ENUM', description: 'Philips Hue defined scene', constraints: HueColorScenes.keySet().sort()]]
command 'stepColorTemperature', [
[name: 'Direction*', type: 'ENUM', description: 'Direction for step change request', constraints: ['up', 'down']],
[name: 'Step Size (Mireds)*', type: 'NUMBER', description: 'Mireds step size (1-300)'],
[name: 'Transition time', type: 'NUMBER', description: 'Transition duration in seconds']
]
command 'stepHueChange', [
[name: 'Direction*', type: 'ENUM', description: 'Direction for step change request', constraints: ['up', 'down']],
[name: 'Step Size*', type: 'NUMBER', description: 'Hue change step size (1-99)'],
[name: 'Transition time', type: 'NUMBER', description: 'Transition duration in seconds']
]
command 'stepLevelChange', [
[name: 'Direction*', type: 'ENUM', description: 'Direction for step change request', constraints: ['up', 'down']],
[name: 'Step Size*', type: 'NUMBER', description: 'Level change step size (1-99)'],
[name: 'Transition time', type: 'NUMBER', description: 'Transition duration in seconds']
]
command 'toggle'
// Tuya bulbs
fingerprint profileId:"0104", endpointId:"01", inClusters:"0003,0004,0005,0006,1000,0008,0300,EF00,0000", outClusters:"0019,000A", model:"TS0505B", manufacturer:"_TZ3210_wxa85bwk", deviceJoinName: "Tuya Bulb" // KK
fingerprint profileId:"0104", endpointId:"01", inClusters:"0003,0004,0005,0006,1000,0008,0300,EF00,0000", outClusters:"0019,000A", model:"TS0505B", manufacturer:"_TZ3210_r5afgmkl", deviceJoinName: "Tuya Bulb" // https://community.hubitat.com/t/tuya-zigbee-bulb/115563?u=kkossev
fingerprint profileId:"0104", endpointId:"01", inClusters:"0003,0004,0005,0006,1000,0008,0300,EF00,0000", outClusters:"0019,000A", model:"TS0505B", manufacturer:"_TZ3210_eejm8dcr", deviceJoinName: "Tuya LED Strip" // https://community.hubitat.com/t/c8-gledopto-light-strip-controllers/114775/9?u=kkossev
fingerprint profileId:"0104", endpointId:"01", inClusters:"0003,0004,0005,0006,1000,0008,0300,EF00,0000", outClusters:"0019,000A", model:"TS0505B", manufacturer:"_TZ3000_qqjaziws", deviceJoinName: "Tuya LED Strip" // https://community.hubitat.com/t/anyone-used-this-tuya-led-strip-controller-with-success/55593/21?u=kkossev
fingerprint profileId:"0104", endpointId:"01", inClusters:"0000,0003,0004,0005,0006,1000,0008,0300,EF00", outClusters:"0019,000A", model:"TS0505B", manufacturer:"_TZ3210_zexrfbzd", deviceJoinName: "Tuya Bulb" // https://community.hubitat.com/t/zigbee-bulb-paired-as-device/107530/3?u=kkossev
fingerprint profileId:"0104", endpointId:"01", inClusters:"0000,0003,0004,0005,0006,1000,0008,0300,EF00", outClusters:"0019,000A", model:"TS0505B", manufacturer:"_TZ3000_cmaky9gq", deviceJoinName: "Ikuu LED Strip" // https://community.hubitat.com/t/mercator-ikuu/70404/191?u=kkossev
// trap for Hubitat F2 bug
fingerprint profileId:"0104", endpointId:"F2", inClusters:"", outClusters:"", model:"unknown", manufacturer:"unknown", deviceJoinName: "Zigbee device affected by Hubitat F2 bug"
}
preferences {
input name: 'levelUpTransition', type: 'enum', title: 'Dim up transition length', options: TransitionOpts.options, defaultValue: TransitionOpts.defaultValue, required: true, description: \
'Changes the speed the light dims up. Increasing the value slows down the transition.'
input name: 'levelDownTransition', type: 'enum', title: 'Dim down transition length', options: TransitionOpts.options, defaultValue: TransitionOpts.defaultValue, required: true, description: \
'Changes the speed the light dims down. Increasing the value slows down the transition.'
input name: 'colorTransitionTime', type: 'enum', title: 'Color transition length', options: TransitionOpts.options, defaultValue: TransitionOpts.defaultValue, required: true, description: \
'Changes the speed the light changes color/temperature. Increasing the value slows down the transition.'
input name: 'levelChangeRate', type: 'enum', title: 'Level change rate', options: LevelRateOpts.options, defaultValue: LevelRateOpts.defaultValue, required: true, description: \
'Changes the speed that the light changes when using start level change until stop level change is sent.'
input name: 'offCommandMode', type: 'enum', title: 'Off command mode', options: OffModeOpts.options, defaultValue: OffModeOpts.defaultValue, required: true, description: \
'Changes off command. Fade out (default), Instant or Dim to zero (On will dim back to previous level).'
input name: 'flashEffect', type: 'enum', title: 'Flash effect', options: IdentifyEffectNames.values(), defaultValue: 'Blink', required: true, description: \
'Changes the effect used when the flash command is used.'
input name: 'powerRestore', type: 'enum', title: 'Power restore mode', options: PowerRestoreOpts.options, defaultValue: PowerRestoreOpts.defaultValue, description: \
'Changes what happens when power to the bulb is restored.'
input name: 'groupbinding1', type: 'number', title: 'Group Bind # 1', range: '-1..65527', description: \
'Specify first Zigbee group number to bind light to.'
input name: 'groupbinding2', type: 'number', title: 'Group Bind # 2', range: '1..65527', description: \
'Specify second Zigbee group number to bind light to.'
input name: 'groupbinding3', type: 'number', title: 'Group Bind # 3', range: '1..65527', description: \
'Specify third Zigbee group number to bind light to.'
input name: 'enableReporting', type: 'bool', title: 'Enable state reporting', defaultValue: true, description: \
'Enables push state updates instead of polling. (Generation 3 or newer with recent firmware)'
input name: 'healthCheckInterval', type: 'enum', title: 'Healthcheck Interval', options: HealthcheckIntervalOpts.options, defaultValue: HealthcheckIntervalOpts.defaultValue, required: true, description: \
'Changes how often the hub pings the bulb to check health.'
input name: 'txtEnable', type: 'bool', title: 'Enable descriptionText logging', defaultValue: true, description: \
'Enables command logging.'
input name: 'logEnable', type: 'bool', title: 'Enable debug logging', defaultValue: false, description: \
'Turns on debug logging for 30 minutes.'
}
}
@Field static final String VERSION = '1.07 (2023-04-08)'
/**
* Send configuration parameters to the bulb
* Invoked when device is first installed and when the user updates the configuration
* @return List of zigbee commands
*/
List configure() {
List cmds = []
log.info 'configure...'
logDebug settings
state.ct = [high: 6536, low: 2000] // default values
state.reportingEnabled = false
device.deleteCurrentState('color') // attribute not used
// Power Restore Behavior
if (settings.powerRestore != null) {
log.info "configure: setting power restore state to 0x${intToHexStr(settings.powerRestore as Integer)}"
cmds += zigbee.writeAttribute(zigbee.ON_OFF_CLUSTER, POWER_RESTORE_ID, DataType.ENUM8, settings.powerRestore as Integer, [destEndpoint:safeToInt(getDestinationEP())], DELAY_MS)
cmds += zigbee.writeAttribute(zigbee.COLOR_CONTROL_CLUSTER, 0x4010, DataType.UINT16, 0xFFFF, [destEndpoint:safeToInt(getDestinationEP())], DELAY_MS)
}
// Add to specified groups (if group is null then remove from previous group if any)
cmds += setGroupMembership()
// Attempt to enable cluster reporting, if it fails we fall back to polling after commands
if (settings.enableReporting == false) {
//cmds += zigbee.configureReporting(PHILIPS_PRIVATE_CLUSTER, HUE_PRIVATE_STATE_ID, DataType.STRING_OCTET, 0, 0xFFFF, null, [mfgCode: PHILIPS_VENDOR], DELAY_MS)
cmds += zigbee.configureReporting(zigbee.COLOR_CONTROL_CLUSTER, 0x00, DataType.UINT8, 0, 0xFFFF, 0, [destEndpoint:safeToInt(getDestinationEP())], DELAY_MS)
cmds += zigbee.configureReporting(zigbee.COLOR_CONTROL_CLUSTER, 0x01, DataType.UINT8, 0, 0xFFFF, 1, [destEndpoint:safeToInt(getDestinationEP())], DELAY_MS)
} else {
log.info 'configure: attempting to enable state reporting'
//cmds += zigbee.configureReporting(PHILIPS_PRIVATE_CLUSTER, HUE_PRIVATE_STATE_ID, DataType.STRING_OCTET, 1, REPORTING_MAX, null, [mfgCode: PHILIPS_VENDOR], DELAY_MS)
cmds += zigbee.configureReporting(zigbee.COLOR_CONTROL_CLUSTER, 0x00, DataType.UINT8, 1, REPORTING_MAX, 1, [destEndpoint:safeToInt(getDestinationEP())], DELAY_MS)
cmds += zigbee.configureReporting(zigbee.COLOR_CONTROL_CLUSTER, 0x01, DataType.UINT8, 1, REPORTING_MAX, 1, [destEndpoint:safeToInt(getDestinationEP())], DELAY_MS)
}
// Unbind unused cluster reporting (on/off, level, XY, CT & mode are reported via private cluster)
cmds += zigbee.configureReporting(zigbee.ON_OFF_CLUSTER, 0x00, DataType.BOOLEAN, 0, 0xFFFF, null, [destEndpoint:safeToInt(getDestinationEP())], DELAY_MS)
cmds += zigbee.configureReporting(zigbee.LEVEL_CONTROL_CLUSTER, 0x00, DataType.UINT8, 0, 0xFFFF, 1, [destEndpoint:safeToInt(getDestinationEP())], DELAY_MS)
cmds += zigbee.configureReporting(zigbee.COLOR_CONTROL_CLUSTER, 0x03, DataType.UINT16, 0, 0xFFFF, 1, [destEndpoint:safeToInt(getDestinationEP())], DELAY_MS)
cmds += zigbee.configureReporting(zigbee.COLOR_CONTROL_CLUSTER, 0x04, DataType.UINT16, 0, 0xFFFF, 1, [destEndpoint:safeToInt(getDestinationEP())], DELAY_MS)
cmds += zigbee.configureReporting(zigbee.COLOR_CONTROL_CLUSTER, 0x07, DataType.UINT16, 0, 0xFFFF, 1, [destEndpoint:safeToInt(getDestinationEP())], DELAY_MS)
if (settings.logEnable) {
log.debug "zigbee configure cmds: ${cmds}"
}
runIn(5, 'refresh')
return cmds
}
/**
* Add or remove bulb from specified group configuration
* @return List of zigbee commands
*/
List setGroupMembership() {
List cmds = []
for (final int i = 1; i <= 3; i++) {
final String config = "groupbinding${i}"
// Remove from previous group if necessary
if (state[config] && state[config] as Integer != settings[config] as Integer) {
final Integer group = state[config] as Integer
log.info "configure: removing from group ${group}"
final String groupHex = DataType.pack(group, DataType.UINT16, true)
cmds += zigbee.command(zigbee.GROUPS_CLUSTER, 0x03, [destEndpoint:safeToInt(getDestinationEP())], DELAY_MS, groupHex)
state.remove(config)
}
// Add to new group if specified
if (settings[config]) {
final Integer group = settings[config] as Integer
if (group >= 1 && group <= 0xFFF7) {
log.info "configure: adding to group ${group}"
final String groupHex = DataType.pack(group, DataType.UINT16, true)
cmds += zigbee.command(zigbee.GROUPS_CLUSTER, 0x00, [destEndpoint:safeToInt(getDestinationEP())], DELAY_MS, "${groupHex} 00")
state[config] = group
}
}
}
return cmds
}
/**
* Send health status event upon a timeout
*/
void deviceCommandTimeout() {
log.warn 'no response received (device offline?)'
sendHealthStatusEvent('offline')
}
/**
* Flash Command
* @param rate not used
* @return List of zigbee commands
*/
List flash(final Object rate = null) {
if (settings.txtEnable) {
log.info "flash (${rate})"
}
return identify(settings.flashEffect as String)
}
/**
* Identify Command
* @param name effect name
* @return List of zigbee commands
*/
List identify(final String name) {
final Integer effect = IdentifyEffectNames.find { final key, final value -> value.equalsIgnoreCase(name) }?.key
if (effect == null) {
return []
}
if (settings.txtEnable) {
log.info "identify (${name})"
}
final String effectStr = DataType.pack(effect, DataType.UINT8)
return zigbee.command(zigbee.IDENTIFY_CLUSTER, 0x40, [destEndpoint:safeToInt(getDestinationEP())], 0, "${effectStr} 00")
}
/**
* Invoked by Hubitat when driver is installed
*/
void installed() {
log.info 'installed'
// populate some default values for attributes
sendEvent(name: 'colorMode', value: 'CT')
sendEvent(name: 'colorTemperature', value: 2700)
//sendEvent(name: 'effectName', value: 'none')
sendEvent(name: 'hue', value: 0, unit: '%')
sendEvent(name: 'level', value: 0, unit: '%')
sendEvent(name: 'saturation', value: 0)
sendEvent(name: 'switch', value: 'off')
sendEvent(name: 'healthStatus', value: 'unknown')
}
/**
* Disable logging (for debugging)
*/
void logsOff() {
log.warn 'debug logging disabled...'
device.updateSetting('logEnable', [value: 'false', type: 'bool'])
}
/**
* Off Command
* @return List of zigbee commands
*/
List off() {
final Integer mode = settings.offCommandMode as Integer
// if off command mode is set to 'previous level' then store the current level and turn off
if (mode == 0xFF) {
state.previousLevel = device.currentValue('level') as Integer
return setLevel(0)
}
if (settings.txtEnable) {
log.info 'turn off'
}
scheduleCommandTimeoutCheck()
final String variant = DataType.pack(mode, DataType.UINT8)
return zigbee.command(zigbee.ON_OFF_CLUSTER, 0x40, [destEndpoint:safeToInt(getDestinationEP())], 0, "00 ${variant}") +
ifPolling { zigbee.onOffRefresh(0) }
}
/**
* On Command
* @return List of zigbee commands
*/
List on() {
final Integer mode = settings.offCommandMode as Integer
// if off command mode is set to 'previous level' then restore the previous level
if (state.previousLevel && mode == 0xFF) {
final Integer level = state.previousLevel as Integer
state.remove('previousLevel')
return setLevel(level)
}
if (settings.txtEnable) {
log.info 'turn on'
}
scheduleCommandTimeoutCheck()
return zigbee.command(zigbee.ON_OFF_CLUSTER, 0x01, [destEndpoint:safeToInt(getDestinationEP())], 0) +
ifPolling { zigbee.onOffRefresh(0) }
}
/**
* Ping Command
* @return List of zigbee commands
*/
List ping() {
if (settings.txtEnable) {
log.info 'ping...'
}
// Using attribute 0x00 as a simple ping/pong mechanism
scheduleCommandTimeoutCheck()
return zigbee.readAttribute(zigbee.BASIC_CLUSTER, PING_ATTR_ID, [destEndpoint:safeToInt(getDestinationEP())], 0)
}
/**
* Preset Level Command
* This will not turn the device on if it is off.
* @param value level percent (0-100)
* @return List of zigbee commands
*/
List presetLevel(final BigDecimal value) {
if (settings.txtEnable) {
log.info "presetLevel (${value})"
}
final Boolean isOn = device.currentValue('switch') == 'on'
final Integer rate = isOn ? getLevelTransitionRate(value as Integer) : 0
scheduleCommandTimeoutCheck()
return setLevelPrivate(value, rate, 0, true)
}
/**
* Refresh Command
* @return List of zigbee commands
*/
List refresh() {
log.info 'refresh'
List cmds = []
// Get Firmware Version
cmds += zigbee.readAttribute(zigbee.BASIC_CLUSTER, FIRMWARE_VERSION_ID, [destEndpoint:safeToInt(getDestinationEP())], DELAY_MS)
// Get Power Restore state
cmds += zigbee.readAttribute(zigbee.ON_OFF_CLUSTER, POWER_RESTORE_ID, [destEndpoint:safeToInt(getDestinationEP())], DELAY_MS)
// Get Minimum/Maximum Color Temperature
state.ct = state.ct ?: [high: 6536, low: 2000] // default values
cmds += zigbee.readAttribute(zigbee.COLOR_CONTROL_CLUSTER, [0x400B, 0x400C], [destEndpoint:safeToInt(getDestinationEP())], DELAY_MS)
// Get device type and supported effects
cmds += zigbee.readAttribute(PHILIPS_PRIVATE_CLUSTER, [0x01, 0x11], [mfgCode: PHILIPS_VENDOR], DELAY_MS)
// Refresh other attributes
cmds += hueStateRefresh(DELAY_MS)
cmds += colorRefresh(DELAY_MS)
// Get group membership
cmds += zigbee.command(zigbee.GROUPS_CLUSTER, 0x02, [destEndpoint:safeToInt(getDestinationEP())], DELAY_MS, '00')
scheduleCommandTimeoutCheck()
return cmds
}
/**
* Set Color Command
* @param value color map (hue, saturation, level, rate)
* @return List of zigbee commands
*/
List setColor(final Map value) {
List cmds = []
if (settings.txtEnable) {
log.info "setColor (${value})"
}
final Boolean isOn = device.currentValue('switch') == 'on'
final Integer hue = constrain(value.hue)
final Integer saturation = constrain(value.saturation)
final Integer rate = isOn ? getColorTransitionRate(value.rate as Integer) : 0
final String rateHex = DataType.pack(rate, DataType.UINT16, true)
final String scaledHueValue = DataType.pack(Math.round(hue * 2.54), DataType.UINT8)
final String scaledSatValue = DataType.pack(Math.round(saturation * 2.54), DataType.UINT8)
if (value.level != null) {
// This will turn on the device if it is off and set level
cmds += setLevelPrivate(value.level, getLevelTransitionRate(value.level as Integer))
}
cmds += zigbee.command(zigbee.COLOR_CONTROL_CLUSTER, 0x06, [destEndpoint:safeToInt(getDestinationEP())], DELAY_MS, "${scaledHueValue} ${scaledSatValue} ${isOn ? rateHex : '0000'} ${PRE_STAGING_OPTION}")
scheduleCommandTimeoutCheck()
return cmds + ifPolling(DELAY_MS + (rate * 100)) { colorRefresh(0) }
}
/**
* Set Color Temperature Command
* @param colorTemperature color temperature (mireds)
* @param level level percent (0-100)
* @param transitionTime transition time (seconds)
* @return List of zigbee commands
*/
List setColorTemperature(final BigDecimal colorTemperature, final BigDecimal level = null, final BigDecimal transitionTime = null) {
List cmds = []
if (settings.txtEnable) {
log.info "setColorTemperature (${colorTemperature}, ${level}, ${transitionTime})"
}
final Boolean isOn = device.currentValue('switch') == 'on'
final Integer rate = isOn ? getColorTransitionRate(transitionTime as Integer) : 0
final String rateHex = DataType.pack(rate, DataType.UINT16, true)
final Integer ct = constrain(colorTemperature, state.ct.low as Integer, state.ct.high as Integer)
final String miredHex = DataType.pack(ctToMired(ct), DataType.UINT16, true)
cmds += zigbee.command(zigbee.COLOR_CONTROL_CLUSTER, 0x0A, [destEndpoint:safeToInt(getDestinationEP())], DELAY_MS, "${miredHex} ${rateHex} ${PRE_STAGING_OPTION}")
if (level != null) {
// This will turn on the device if it is off and set level
cmds += setLevelPrivate(level, getLevelTransitionRate(level as Integer, transitionTime as Integer))
}
scheduleCommandTimeoutCheck()
return cmds + ifPolling(DELAY_MS + (rate * 100)) { colorRefresh(0) }
}
/**
* Set Color XY Command
* @param colorX color X
* @param colorY color Y
* @param level level percent (0-100)
* @param transitionTime transition time (seconds)
* @return List of zigbee commands
*/
List setColorXy(final BigDecimal colorX, final BigDecimal colorY, final BigDecimal level = null, final BigDecimal transitionTime = null) {
List cmds = []
if (settings.txtEnable) {
log.info "setColorXy (${colorX}, ${colorY}, ${level})"
}
final Boolean isOn = device.currentValue('switch') == 'on'
final int intX = Math.round(constrain(colorX) * 0xFFFF).intValue() // 0..65279
final int intY = Math.round(constrain(colorY) * 0xFFFF).intValue() // 0..65279
final Integer rate = isOn ? getColorTransitionRate(transitionTime as Integer) : 0
final String hexX = DataType.pack(intX, DataType.UINT16, true)
final String hexY = DataType.pack(intY, DataType.UINT16, true)
final String rateHex = DataType.pack(rate, DataType.UINT16, true)
if (level != null) {
// This will turn on the device if it is off and set level
cmds += setLevelPrivate(level, getLevelTransitionRate(level as Integer))
}
cmds += zigbee.command(zigbee.COLOR_CONTROL_CLUSTER, 0x07, [destEndpoint:safeToInt(getDestinationEP())], DELAY_MS, "${hexX} ${hexY} ${isOn ? rateHex : '0000'} ${PRE_STAGING_OPTION}")
scheduleCommandTimeoutCheck()
return cmds + ifPolling(DELAY_MS + (rate * 100)) { colorRefresh(0) }
}
/**
* Set Effect Command
* @param number effect number
* @return List of zigbee commands
*/
List setEffect(final BigDecimal number) {
logDebug "setEfect not supported (yet)!"
return ''
final List effectNames = parseJson(device.currentValue('lightEffects') ?: '[]')
final Integer effectNumber = constrain(number, 0, effectNames.size())
if (settings.txtEnable) {
log.info "setEffect (${number})"
}
if (effectNumber == 0) {
state.remove('effect')
return zigbee.command(PHILIPS_PRIVATE_CLUSTER, 0x00, [mfgCode: PHILIPS_VENDOR], 0, '2000 00')
}
final String effectName = effectNames[effectNumber - 1]
state.effect = number
final int effect = HueEffectNames.find { final key, final value -> value == effectName }?.key
final String effectHex = DataType.pack(effect, DataType.UINT8)
scheduleCommandTimeoutCheck()
return zigbee.command(PHILIPS_PRIVATE_CLUSTER, 0x00, [mfgCode: PHILIPS_VENDOR], 0, "2100 01 ${effectHex}") +
ifPolling { hueStateRefresh(0) }
}
/**
* Set Enhanced Hue Command.
* This will not turn the device on if it is off.
* @param value hue value (0-360)
* @return List of zigbee commands
*/
List setEnhancedHue(final BigDecimal value) {
if (settings.txtEnable) {
log.info "setEnhancedHue (${value})"
}
final Boolean isOn = device.currentValue('switch') == 'on'
final Integer hue = constrain(value, 0, 360)
final Integer rate = isOn ? getColorTransitionRate() : 0
final String rateHex = DataType.pack(rate, DataType.UINT16, true)
final String scaledHueHex = DataType.pack(Math.round(hue * 182.04444).intValue(), DataType.UINT16, true)
scheduleCommandTimeoutCheck()
return zigbee.command(zigbee.COLOR_CONTROL_CLUSTER, 0x40, [destEndpoint:safeToInt(getDestinationEP())], 0, "${scaledHueHex} 00 ${rateHex} ${PRE_STAGING_OPTION}") +
ifPolling(DELAY_MS + (rate * 100)) { colorRefresh(0) }
}
/**
* Set Hue Command
* This will not turn the device on if it is off.
* @param value hue value (0-100)
* @return List of zigbee commands
*/
List setHue(final BigDecimal value) {
if (settings.txtEnable) {
log.info "setHue (${value})"
}
final Boolean isOn = device.currentValue('switch') == 'on'
final Integer hue = (Integer) constrain(value)
final Integer rate = isOn ? getColorTransitionRate() : 0
final String rateHex = DataType.pack(rate, DataType.UINT16, true)
final String scaledHueHex = DataType.pack(Math.round(hue * 2.54).intValue(), DataType.UINT8)
scheduleCommandTimeoutCheck()
return zigbee.command(zigbee.COLOR_CONTROL_CLUSTER, 0x00, [destEndpoint:safeToInt(getDestinationEP())], 0, "${scaledHueHex} 00 ${rateHex} ${PRE_STAGING_OPTION}") +
ifPolling(DELAY_MS + (rate * 100)) { colorRefresh(0) }
}
/**
* Set Scene Command
* @param name scene name from HueColorScenes
* @return List of zigbee commands
*/
List setScene(final String name) {
List cmds = []
final Map formula = HueColorScenes[name] as Map
if (!formula) {
return []
}
final Boolean isOn = device.currentValue('switch') == 'on'
final Integer rate = isOn ? getColorTransitionRate() : 0
final String rateHex = DataType.pack(rate, DataType.UINT16, true)
final String scaledHueHex = DataType.pack(Math.round((int)formula.hue * 182.04444).intValue(), DataType.UINT8)
final String scaledSatHex = DataType.pack(Math.round((int)formula.saturation * 2.54).intValue(), DataType.UINT8)
scheduleCommandTimeoutCheck()
cmds += setLevelPrivate(formula.brightness, getLevelTransitionRate(formula.brightness as Integer))
cmds += zigbee.command(zigbee.COLOR_CONTROL_CLUSTER, 0x43, [destEndpoint:safeToInt(getDestinationEP())], 0, "${scaledHueHex} ${scaledSatHex} ${rateHex} 00 ${PRE_STAGING_OPTION}")
cmds += ifPolling(DELAY_MS + (rate * 100)) { colorRefresh(0) }
return cmds as List
}
/**
* Set Level Command
* @param value level percent (0-100)
* @param transitionTime transition time in seconds
* @return List of zigbee commands
*/
List setLevel(final Object value, final Object transitionTime = null) {
if (settings.txtEnable) {
log.info "setLevel (${value}, ${transitionTime})"
}
final Integer rate = getLevelTransitionRate(value as Integer, transitionTime as Integer)
scheduleCommandTimeoutCheck()
return setLevelPrivate(value, rate)
}
/**
* Set Next Effect Command
* @return List of zigbee commands
*/
List setNextEffect() {
if (settings.txtEnable) {
log.info 'setNextEffect'
}
BigDecimal number = state.effect ? state.effect + 1 : 1
if (number > HueEffectNames.size()) {
number = 1
}
return setEffect(number)
}
/**
* Set Previous Effect Command
* @return List of zigbee commands
*/
List setPreviousEffect() {
if (settings.txtEnable) {
log.info 'setPreviousEffect'
}
BigDecimal number = state.effect ? state.effect - 1 : HueEffectNames.size()
if (number < 1) {
number = 1
}
return setEffect(number)
}
/**
* Set Saturation Command
* This will not turn the device on if it is off.
* @param value saturation value (0-100)
* @return List of zigbee commands
*/
List setSaturation(final BigDecimal value) {
if (settings.txtEnable) {
log.info "setSaturation (${value})"
}
final Boolean isOn = device.currentValue('switch') == 'on'
final Integer saturation = (Integer) constrain(value)
final Integer rate = isOn ? getColorTransitionRate() : 0
final String rateHex = DataType.pack(rate, DataType.UINT16, true)
final String scaledSatHex = DataType.pack(Math.round(saturation * 2.54), DataType.UINT8)
scheduleCommandTimeoutCheck()
return zigbee.command(zigbee.COLOR_CONTROL_CLUSTER, 0x03, [destEndpoint:safeToInt(getDestinationEP())], 0, "${scaledSatHex} 00 ${rateHex} ${PRE_STAGING_OPTION}") +
ifPolling(DELAY_MS + (rate * 100)) { colorRefresh(0) }
}
/**
* Start Level Change Command
* @param direction direction to change level (up/down)
* @return List of zigbee commands
*/
List startLevelChange(final String direction) {
if (settings.txtEnable) {
log.info "startLevelChange (${direction})"
}
final String upDown = direction == 'down' ? '01' : '00'
final String rateHex = DataType.pack(settings.levelChangeRate as Integer, DataType.UINT8)
scheduleCommandTimeoutCheck()
return zigbee.command(zigbee.LEVEL_CONTROL_CLUSTER, 0x05, [destEndpoint:safeToInt(getDestinationEP())], 0, "${upDown} ${rateHex}")
}
/**
* Step Color Temperature Command
* @param direction direction to change color temperature (up/down)
* @param stepSize step size in mireds
* @param transitionTime transition time in seconds
* @return List of zigbee commands
*/
List stepColorTemperature(final String direction, final BigDecimal stepSize, final BigDecimal transitionTime = null) {
if (settings.txtEnable) {
log.info "stepColorTemperatureChange (${direction}, ${stepSize}, ${transitionTime})"
}
final Integer rate = getColorTransitionRate(transitionTime as Integer)
final String rateHex = DataType.pack(rate, DataType.UINT16, true)
final String stepHex = DataType.pack(constrain(stepSize.toInteger(), 1, 300), DataType.UINT16, true)
final String upDownHex = direction == 'down' ? '01' : '03'
scheduleCommandTimeoutCheck()
return zigbee.command(zigbee.COLOR_CONTROL_CLUSTER, 0x4C, [destEndpoint:safeToInt(getDestinationEP())], 0, "${upDownHex} ${stepHex} ${rateHex} 0000 0000") +
ifPolling { zigbee.colorRefresh(0) }
}
/**
* Step Hue Command
* @param direction direction to change hue (up/down)
* @param stepSize step size in degrees
* @param transitionTime transition time in seconds
* @return List of zigbee commands
*/
List stepHueChange(final String direction, final BigDecimal stepSize, final BigDecimal transitionTime = null) {
if (settings.txtEnable) {
log.info "stepHueChange (${direction}, ${stepSize}, ${transitionTime})"
}
final Integer rate = getColorTransitionRate(transitionTime as Integer)
final String rateHex = DataType.pack(rate, DataType.UINT16, true)
final Integer level = constrain(stepSize, 1, 99)
final String stepHex = DataType.pack((level * 2.55).toInteger(), DataType.UINT8)
final String upDownHex = direction == 'down' ? '03' : '01'
scheduleCommandTimeoutCheck()
return zigbee.command(zigbee.COLOR_CONTROL_CLUSTER, 0x02, [destEndpoint:safeToInt(getDestinationEP())], 0, "${upDownHex} ${stepHex} ${rateHex}") +
ifPolling { zigbee.colorRefresh(0) }
}
/**
* Step Level Command
* @param direction direction to change level (up/down)
* @param stepSize step size in percent
* @param transitionTime transition time in seconds
* @return List of zigbee commands
*/
List stepLevelChange(final String direction, final BigDecimal stepSize, final BigDecimal transitionTime = null) {
if (settings.txtEnable) {
log.info "stepLevelChange (${direction}, ${stepSize}, ${transitionTime})"
}
final Integer rate = getLevelTransitionRate(direction == 'down' ? 0 : 100, transitionTime as Integer)
final String rateHex = DataType.pack(rate, DataType.UINT16, true)
final Integer level = constrain(stepSize, 1, 99)
final String stepHex = DataType.pack((level * 2.55).toInteger(), DataType.UINT8)
final String upDownHex = direction == 'down' ? '01' : '00'
scheduleCommandTimeoutCheck()
return zigbee.command(zigbee.LEVEL_CONTROL_CLUSTER, 0x06, [destEndpoint:safeToInt(getDestinationEP())], 0, "${upDownHex} ${stepHex} ${rateHex}") +
ifPolling { zigbee.levelRefresh(0) + zigbee.onOffRefresh(0) }
}
/**
* Stop Level Change Command
* @return List of zigbee commands
*/
List stopLevelChange() {
if (settings.txtEnable) {
log.info 'stopLevelChange'
}
scheduleCommandTimeoutCheck()
return zigbee.command(zigbee.LEVEL_CONTROL_CLUSTER, 0x03, [destEndpoint:safeToInt(getDestinationEP())], 0) +
ifPolling { zigbee.levelRefresh(0) + zigbee.onOffRefresh(0) }
}
/**
* Toggle Command (On/Off)
* @return List of zigbee commands
*/
List toggle() {
if (settings.txtEnable) {
log.info 'toggle'
}
scheduleCommandTimeoutCheck()
return zigbee.command(zigbee.ON_OFF_CLUSTER, 0x02, [destEndpoint:safeToInt(getDestinationEP())], 0) +
ifPolling { zigbee.onOffRefresh(0) }
}
/**
* Invoked by Hubitat when the driver configuration is updated
*/
void updated() {
log.info 'updated...'
log.info "driver version ${VERSION}"
unschedule()
if (settings.logEnable) {
log.debug settings
runIn(1800, logsOff)
}
final int interval = (settings.healthCheckInterval as Integer) ?: 0
if (interval > 0) {
log.info "scheduling health check every ${interval} minutes"
scheduleDeviceHealthCheck(interval)
}
runIn(1, 'configure')
}
/**
* Parse Zigbee message
* @param description Zigbee message in hex format
*/
void parse(final String description) {
final Map descMap = zigbee.parseDescriptionAsMap(description)
sendHealthStatusEvent('online')
unschedule('deviceCommandTimeout')
if (descMap.profileId == '0000') {
parseZdoClusters(descMap)
return
}
if (descMap.isClusterSpecific == false) {
parseGeneralCommandResponse(descMap)
return
}
if (settings.logEnable) {
final String clusterName = clusterLookup(descMap.clusterInt) ?: "PRIVATE_CLUSTER (0x${descMap.cluster})"
final String attribute = descMap.attrId ? " attribute 0x${descMap.attrId} (value ${descMap.value})" : ''
log.trace "zigbee received ${clusterName} message" + attribute
}
switch (descMap.clusterInt as Integer) {
case zigbee.BASIC_CLUSTER:
parseBasicCluster(descMap)
descMap.remove('additionalAttrs')?.each { final Map map -> parseBasicCluster(map) }
break
case zigbee.COLOR_CONTROL_CLUSTER:
parseColorCluster(descMap)
descMap.remove('additionalAttrs')?.each { final Map map -> parseColorCluster(map) }
break
case zigbee.GROUPS_CLUSTER:
parseGroupsCluster(descMap)
descMap.remove('additionalAttrs')?.each { final Map map -> parseGroupsCluster(map) }
break
case PHILIPS_PRIVATE_CLUSTER:
parsePrivateCluster(descMap)
descMap.remove('additionalAttrs')?.each { final Map map -> parsePrivateCluster(map) }
break
case zigbee.LEVEL_CONTROL_CLUSTER:
parseLevelCluster(descMap)
descMap.remove('additionalAttrs')?.each { final Map map -> parseLevelCluster(map) }
break
case zigbee.ON_OFF_CLUSTER:
parseOnOffCluster(descMap)
descMap.remove('additionalAttrs')?.each { final Map map -> parseOnOffCluster(map) }
break
default:
if (settings.logEnable) {
log.debug "zigbee received unknown message cluster: ${descMap}"
}
break
}
}
/**
* Zigbee Basic Cluster Parsing
* @param descMap Zigbee message in parsed map format
*/
void parseBasicCluster(final Map descMap) {
switch (descMap.attrInt as Integer) {
case PING_ATTR_ID: // Using 0x01 read as a simple ping/pong mechanism
if (settings.txtEnable) {
log.info 'pong..'
}
break
case FIRMWARE_VERSION_ID:
final String version = descMap.value ?: 'unknown'
log.info "device firmware version is ${version}"
updateDataValue('softwareBuild', version)
break
case PRODUCT_ID:
final String name = descMap.value ?: 'unknown'
log.info "device product identifier is ${name}"
updateDataValue('productIdentifier', name)
break
default:
log.warn "zigbee received unknown BASIC_CLUSTER: ${descMap}"
break
}
}
/**
* Zigbee Color Cluster Parsing
* @param descMap Zigbee message in parsed map format
*/
void parseColorCluster(final Map descMap) {
switch (descMap.attrInt as Integer) {
case 0x00: // hue
sendHueEvent(descMap.value as String)
break
case 0x01: // saturation
sendSaturationEvent(descMap.value as String)
break
case 0x03: // current X
if (settings.logEnable) {
log.debug 'ignoring X color attribute'
}
break
case 0x04: // current Y
if (settings.logEnable) {
log.debug 'ignoring Y color attribute'
}
break
case 0x07: // ct
sendColorTempEvent(descMap.value as String)
break
case 0x08: // color mode
final String mode = descMap.value == '02' ? 'CT' : 'RGB'
sendColorModeEvent(mode)
break
case 0x400B:
state.ct = state.ct ?: [:]
state.ct.high = Math.round(1000000 / hexStrToUnsignedInt(descMap.value) as Long)
log.info "color temperature high set to ${state.ct.high}K"
break
case 0x400C:
state.ct = state.ct ?: [:]
state.ct.low = Math.round(1000000 / hexStrToUnsignedInt(descMap.value) as Long)
log.info "color temperature low set to ${state.ct.low}K"
break
default:
log.debug "zigbee received COLOR_CLUSTER: ${descMap}"
break
}
}
/**
* Zigbee General Command Parsing
* @param descMap Zigbee message in parsed map format
*/
void parseGeneralCommandResponse(final Map descMap) {
final int commandId = hexStrToUnsignedInt(descMap.command)
switch (commandId) {
case 0x01: // read attribute response
parseReadAttributeResponse(descMap)
break
case 0x04: // write attribute response
parseWriteAttributeResponse(descMap)
break
case 0x07: // configure reporting response
final String status = ((List)descMap.data).first()
final int statusCode = hexStrToUnsignedInt(status)
if (statusCode == 0x00 && settings.enableReporting != false) {
state.reportingEnabled = true
}
final String statusName = ZigbeeStatusEnum[statusCode] ?: "0x${status}"
if (statusCode > 0x00) {
log.warn "zigbee configure reporting error: ${statusName} ${descMap.data}"
} else if (settings.logEnable) {
log.trace "zigbee configure reporting response: ${statusName} ${descMap.data}"
}
break
case 0x0B: // default command response
parseDefaultCommandResponse(descMap)
break
default:
final String commandName = ZigbeeGeneralCommandEnum[commandId] ?: "UNKNOWN_COMMAND (0x${descMap.command})"
final String clusterName = clusterLookup(descMap.clusterInt)
final String status = descMap.data in List ? ((List)descMap.data).last() : descMap.data
final int statusCode = hexStrToUnsignedInt(status)
final String statusName = ZigbeeStatusEnum[statusCode] ?: "0x${status}"
if (statusCode > 0x00) {
log.warn "zigbee ${commandName} ${clusterName} error: ${statusName}"
} else if (settings.logEnable) {
log.trace "zigbee ${commandName} ${clusterName}: ${descMap.data}"
}
break
}
}
/**
* Zigbee Default Command Response Parsing
* @param descMap Zigbee message in parsed map format
*/
void parseDefaultCommandResponse(final Map descMap) {
final List data = descMap.data as List
final String commandId = data[0]
final int statusCode = hexStrToUnsignedInt(data[1])
final String status = ZigbeeStatusEnum[statusCode] ?: "0x${data[1]}"
if (statusCode > 0x00) {
log.warn "zigbee ${clusterLookup(descMap.clusterInt)} command 0x${commandId} error: ${status}"
} else if (settings.logEnable) {
log.trace "zigbee ${clusterLookup(descMap.clusterInt)} command 0x${commandId} response: ${status}"
}
}
/**
* Zigbee Read Attribute Response Parsing
* @param descMap Zigbee message in parsed map format
*/
void parseReadAttributeResponse(final Map descMap) {
final List data = descMap.data as List
final String attribute = data[1] + data[0]
final int statusCode = hexStrToUnsignedInt(data[2])
final String status = ZigbeeStatusEnum[statusCode] ?: "0x${data}"
if (settings.logEnable) {
log.trace "zigbee read ${clusterLookup(descMap.clusterInt)} attribute 0x${attribute} response: ${status} ${data}"
} else if (statusCode > 0x00) {
log.warn "zigbee read ${clusterLookup(descMap.clusterInt)} attribute 0x${attribute} error: ${status}"
}
}
/**
* Zigbee Write Attribute Response Parsing
* @param descMap Zigbee message in parsed map format
*/
void parseWriteAttributeResponse(final Map descMap) {
final String data = descMap.data in List ? ((List)descMap.data).first() : descMap.data
final int statusCode = hexStrToUnsignedInt(data)
final String statusName = ZigbeeStatusEnum[statusCode] ?: "0x${data}"
if (settings.logEnable) {
log.trace "zigbee response write ${clusterLookup(descMap.clusterInt)} attribute response: ${statusName}"
} else if (statusCode > 0x00) {
log.warn "zigbee response write ${clusterLookup(descMap.clusterInt)} attribute error: ${statusName}"
}
}
/**
* Zigbee Groups Cluster Parsing
* @param descMap Zigbee message in parsed map format
*/
void parseGroupsCluster(final Map descMap) {
switch (descMap.command as Integer) {
case 0x00: // Add group response
final List data = descMap.data as List
final int statusCode = hexStrToUnsignedInt(data[0])
final String statusName = ZigbeeStatusEnum[statusCode] ?: "0x${data[0]}"
final int groupId = hexStrToUnsignedInt(data[2] + data[1])
if (settings.logEnable) {
log.trace "zigbee response add group ${groupId}: ${statusName}"
} else if (statusCode > 0x00) {
log.warn "zigbee response add group ${groupId} error: ${statusName}"
}
break
case 0x02: // Group membership response
final List data = descMap.data as List
final int capacity = hexStrToUnsignedInt(data[0])
final int groupCount = hexStrToUnsignedInt(data[1])
final Set groups = []
for (int i = 0; i < groupCount; i++) {
int pos = (i * 2) + 2
String group = hexStrToUnsignedInt(data[pos + 1] + data[pos])
groups.add(group)
}
state.groups = groups
log.info "zigbee group memberships: ${groups} (capacity available: ${capacity})"
break
default:
log.warn "zigbee received unknown GROUPS cluster: ${descMap}"
break
}
}
/**
* Zigbee Hue Specific Cluster Parsing
* @param descMap Zigbee message in parsed map format
*/
void parsePrivateCluster(final Map descMap) {
switch (descMap.attrInt as Integer) {
case 0x01: // device type
parsePrivateClusterDeviceType(descMap)
break
case HUE_PRIVATE_STATE_ID: // current state
parsePrivateClusterState(descMap)
break
case 0x11: // available effects
parsePrivateClusterEffects(descMap)
break
default:
log.warn "zigbee received unknown PRIVATE_CLUSTER: ${descMap}"
break
}
}
/*
* Known Device type values
* 0x03 - ZLL
* 0x05 - White Only ZB3
* 0x07 - White and Color ZB3
*/
/**
* Parse the private cluster device type attribute
* @param descMap Zigbee message in parsed map format
*/
void parsePrivateClusterDeviceType(final Map descMap) {
final BigInteger deviceTypeValue = new BigInteger(descMap.value as String, 16)
final String deviceType = deviceTypeValue.toString(16).toUpperCase()
log.info "detected light type: ${deviceType}"
updateDataValue('type', deviceType)
}
/**
* Parse the private cluster effects attribute
* ENUM64 bitmap position corresponds to the effect number
* @param descMap Zigbee message in parsed map format
*/
void parsePrivateClusterEffects(final Map descMap) {
final BigInteger effectsMap = new BigInteger(descMap.value as String, 16)
final Map effects = HueEffectNames.findAll { final key, final value -> effectsMap.testBit(key) }
log.info "supported light effects: ${effects.values()}"
sendEvent(name: 'lightEffects', value: JsonOutput.toJson(effects.values()))
}
/**
* Parse the private cluster state attribute
* Attribute 0x0002 encodes the light state, where the first
* two bytes indicate the mode, the next byte OnOff, the next byte
* Current Level, and the following bytes the mode-specific state.
* from https://github.com/dresden-elektronik/deconz-rest-plugin/issues/5891
* @param descMap Zigbee message in parsed map format
*/
void parsePrivateClusterState(final Map descMap) {
final String value = descMap.value
final int mode = hexStrToUnsignedInt(zigbee.swapOctets(value[0..3]))
final boolean isOn = hexStrToUnsignedInt(value[4..5]) == 1
final int level = hexStrToUnsignedInt(value[6..7])
if (settings.logEnable) {
log.debug "zigbee received private cluster report (length ${value.size()}) [power: ${isOn}, level: ${level}, mode: 0x${intToHexStr(mode, 2)}]"
}
sendSwitchEvent(isOn)
switch (mode) {
case 0x0003: // Brightness mode
sendLevelEvent(level)
sendEffectNameEvent()
break
case 0x00A3: // Brightness with Effect
sendLevelEvent(level)
sendEffectNameEvent(value[8..9])
break
case 0x0007: // Color Temperature mode
case 0x000F:
sendColorTempEvent(value[8..11])
sendColorModeEvent('CT')
sendLevelEvent(level)
sendEffectNameEvent()
break
case 0x000B: // XY mode
sendColorModeEvent('RGB')
sendLevelEvent(level)
sendColorXyEvent(value[8..15])
sendEffectNameEvent()
break
case 0x00AB: // XY mode with effect
sendColorModeEvent('RGB')
sendLevelEvent(level)
sendColorXyEvent(value[8..15])
sendEffectNameEvent(value[16..17])
break
default:
log.warn "Unknown mode received: 0x${intToHexStr(mode)}"
break
}
}
/**
* Parse the Level Control cluster attribute
* @param descMap Zigbee message in parsed map format
*/
void parseLevelCluster(final Map descMap) {
switch (descMap.attrInt as Integer) {
case 0x00:
sendLevelEvent(hexStrToUnsignedInt(descMap.value))
break
default:
log.warn "zigbee received unknown LEVEL_CONTROL cluster: ${descMap}"
break
}
}
/**
* Parse the On Off cluster attribute
* @param descMap Zigbee message in parsed map format
*/
void parseOnOffCluster(final Map descMap) {
switch (descMap.attrInt as Integer) {
case 0x00:
sendSwitchEvent(descMap.value == '01')
break
case POWER_RESTORE_ID:
final Integer value = hexStrToUnsignedInt(descMap.value)
final Map options = PowerRestoreOpts.options as Map
log.info "power restore mode is '${options[value]}' (0x${descMap.value})"
device.updateSetting('powerRestore', [value: value.toString(), type: 'enum'])
break
default:
log.warn "zigbee received unknown ON_OFF_CLUSTER: ${descMap}"
break
}
}
/**
* ZDO (Zigbee Data Object) Clusters Parsing
* @param descMap Zigbee message in parsed map format
*/
void parseZdoClusters(final Map descMap) {
final Integer clusterId = descMap.clusterInt as Integer
final String clusterName = ZdoClusterEnum[clusterId] ?: "UNKNOWN_CLUSTER (0x${descMap.clusterId})"
final String statusHex = ((List)descMap.data)[1]
final Integer statusCode = hexStrToUnsignedInt(statusHex)
final String statusName = ZigbeeStatusEnum[statusCode] ?: "0x${statusHex}"
if (statusCode > 0x00) {
log.warn "zigbee received device object ${clusterName} error: ${statusName}"
} else if (settings.logEnable) {
log.trace "zigbee received device object ${clusterName} success: ${descMap.data}"
}
}
/*-------------------------- END OF ZIGBEE PARSING --------------------*/
/**
* Constrain a value to a range
* @param value value to constrain
* @param min minimum value (default 0)
* @param max maximum value (default 100)
* @param nullValue value to return if value is null (default 0)
*/
private static BigDecimal constrain(final BigDecimal value, final BigDecimal min = 0, final BigDecimal max = 100, final BigDecimal nullValue = 0) {
if (min == null || max == null) {
return value
}
return value != null ? max.min(value.max(min)) : nullValue
}
/**
* Constrain a value to a range
* @param value value to constrain
* @param min minimum value (default 0)
* @param max maximum value (default 100)
* @param nullValue value to return if value is null (default 0)
*/
private static Integer constrain(final Object value, final Integer min = 0, final Integer max = 100, final Integer nullValue = 0) {
if (min == null || max == null) {
return value as Integer
}
return value != null ? Math.min(Math.max(value as Integer, min) as Integer, max) : nullValue
}
/**
* Convert a color temperature in Kelvin to a mired value
* @param kelvin color temperature in Kelvin
* @return mired value
*/
private static Integer ctToMired(final int kelvin) {
return (1000000 / kelvin).toInteger()
}
/**
* Read the color attributes from the Color Control cluster
* @param delayMs delay in milliseconds between each attribute read
* @return list of commands to be sent to the device
*/
private List colorRefresh(final int delayMs = 2000) {
return zigbee.readAttribute(zigbee.COLOR_CONTROL_CLUSTER, [0x00, 0x01, 0x07, 0x08], [destEndpoint:safeToInt(getDestinationEP())], delayMs)
}
/**
* Lookup the cluster name from the cluster ID
* @param cluster cluster ID
* @return cluster name if known, otherwise "private cluster"
*/
private String clusterLookup(final Object cluster) {
return zigbee.clusterLookup(cluster.toInteger()) ?: "private cluster 0x${intToHexStr(cluster.toInteger())}"
}
/**
* Get color transition rate
* @param transitionTime transition time in seconds (optional)
* @return transition rate in 1/10ths of a second
*/
private Integer getColorTransitionRate(final Integer transitionTime = null) {
Integer rate = 0
if (transitionTime != null) {
rate = transitionTime * 10
} else if (settings.colorTransitionTime != null) {
rate = settings.colorTransitionTime.toInteger()
}
if (settings.logEnable) {
log.debug "using color transition rate ${rate}"
}
return rate
}
/**
* Get the level transition rate
* @param level desired target level (0-100)
* @param transitionTime transition time in seconds (optional)
* @return transition rate in 1/10ths of a second
*/
private Integer getLevelTransitionRate(final Integer desiredLevel, final Integer transitionTime = null) {
int rate = 0
final Boolean isOn = device.currentValue('switch') == 'on'
Integer currentLevel = (device.currentValue('level') as Integer) ?: 0
if (!isOn) {
currentLevel = 0
}
// Check if 'transitionTime' has a value
if (transitionTime > 0) {
// Calculate the rate by converting 'transitionTime' to BigDecimal, multiplying by 10, and converting to Integer
rate = transitionTime * 10
} else {
// Check if the 'levelUpTransition' setting has a value and the current level is less than the desired level
if ((settings.levelUpTransition as Integer) > 0 && currentLevel < desiredLevel) {
// Set the rate to the value of the 'levelUpTransition' setting converted to Integer
rate = settings.levelUpTransition.toInteger()
}
// Check if the 'levelDownTransition' setting has a value and the current level is greater than the desired level
else if ((settings.levelDownTransition as Integer) > 0 && currentLevel > desiredLevel) {
// Set the rate to the value of the 'levelDownTransition' setting converted to Integer
rate = settings.levelDownTransition.toInteger()
}
}
if (settings.logEnable) {
log.debug "using level transition rate ${rate}"
}
return rate
}
/**
* Refresh the Philips Hue private cluster state attribute
* @param delayMs delay in milliseconds between each attribute read
* @return list of commands to be sent to the device
*/
private List hueStateRefresh(final int delayMs = 2000) {
return zigbee.readAttribute(PHILIPS_PRIVATE_CLUSTER, HUE_PRIVATE_STATE_ID, [mfgCode: PHILIPS_VENDOR], delayMs)
}
/**
* If the device is polling, delay the execution of the provided commands
* @param delayMs delay in milliseconds
* @param commands commands to execute
* @return list of commands to be sent to the device
*/
private List ifPolling(final int delayMs = 0, final Closure commands) {
if (state.reportingEnabled == false) {
final int value = Math.max(delayMs, POLL_DELAY_MS)
return ["delay ${value}"] + (commands() as List) as List
}
return []
}
/**
* Mired to Kelvin conversion
* @param mired mired value in hex
* @return color temperature in Kelvin
*/
private int miredHexToCt(final String mired) {
return (1000000 / hexStrToUnsignedInt(zigbee.swapOctets(mired))) as int
}
/**
* Schedule a command timeout check
* @param delay delay in seconds (default COMMAND_TIMEOUT)
*/
private void scheduleCommandTimeoutCheck(final int delay = COMMAND_TIMEOUT) {
runIn(delay, 'deviceCommandTimeout')
}
/**
* Schedule a device health check
* @param intervalMins interval in minutes
*/
private void scheduleDeviceHealthCheck(final int intervalMins) {
final Random rnd = new Random()
schedule("${rnd.nextInt(59)} ${rnd.nextInt(9)}/${intervalMins} * ? * * *", 'ping')
}
/**
* Send 'hue' attribute event
* @param rawValue raw hue attribute value
*/
private void sendHueEvent(final String rawValue) {
final long value = hexStrToUnsignedInt(rawValue)
final int hue = Math.round(value / 2.54).intValue()
final String descriptionText = "hue was set to ${hue}"
if (device.currentValue('hue') as Integer != hue && settings.txtEnable) {
log.info descriptionText
}
sendEvent(name: 'hue', value: hue, descriptionText: descriptionText)
}
/**
* Send 'saturation' attribute event
* @param rawValue raw saturation attribute value
*/
private void sendSaturationEvent(final String rawValue) {
final long value = hexStrToUnsignedInt(rawValue)
final int saturation = Math.round(value / 2.54).intValue()
final String descriptionText = "saturation was set to ${saturation}"
if (device.currentValue('saturation') as Integer != saturation && settings.txtEnable) {
log.info descriptionText
}
sendEvent(name: 'saturation', value: saturation, descriptionText: descriptionText)
}
/**
* Send 'colorMode' attribute event
* @param rawValue raw color mode attribute value
*/
private void sendColorModeEvent(final String mode) {
final String descriptionText = "color mode was set to ${mode}"
if (device.currentValue('colorMode') != mode && settings.txtEnable) {
log.info descriptionText
}
sendEvent(name: 'colorMode', value: mode, descriptionText: descriptionText)
if (mode == 'CT') {
sendColorTempNameEvent(device.currentValue('colorTemperature') as Integer)
} else {
sendColorNameEvent(device.currentValue('hue') as Integer, device.currentValue('saturation') as Integer)
}
}
/**
* Send 'colorName' attribute event
* @param hue hue value
* @param saturation saturation value
*/
private void sendColorNameEvent(final Integer hue, final Integer saturation) {
final String colorName = convertHueToGenericColorName(hue, saturation)
if (!colorName) {
return
}
descriptionText = "color name was set to ${colorName}"
if (device.currentValue('colorName') != colorName && settings.txtEnable) {
log.info descriptionText
}
sendEvent name: 'colorName', value: colorName, descriptionText: descriptionText
}
/**
* Send 'colorXy' attribute event
* @param rawValue raw color xy attribute value
*/
private void sendColorXyEvent(final String rawValue) {
final BigDecimal colorX = hexStrToUnsignedInt(zigbee.swapOctets(rawValue[0..3])) / 0xFFFF
final BigDecimal colorY = hexStrToUnsignedInt(zigbee.swapOctets(rawValue[4..7])) / 0xFFFF
if (settings.logEnable) {
log.debug "colorX: ${colorX}, colorY: ${colorY}"
}
}
/**
* Send 'colorTemperature' attribute event
* @param rawValue raw color temperature attribute value
*/
private void sendColorTempEvent(final String rawValue) {
final Integer value = miredHexToCt(rawValue)
if (state.ct.high && value > state.ct.high) {
return
}
if (state.ct.low && value < state.ct.low) {
return
}
final String descriptionText = "color temperature was set to ${value}°K"
if (device.currentValue('colorTemperature') as Integer != value && settings.txtEnable) {
log.info descriptionText
}
sendEvent(name: 'colorTemperature', value: value, descriptionText: descriptionText, unit: '°K')
}
/**
* Send 'colorTempName' attribute event
* @param kelvin color temperature in Kelvin
*/
private void sendColorTempNameEvent(final Integer kelvin) {
final String genericName = convertTemperatureToGenericColorName(kelvin)
if (!genericName) {
return
}
final String descriptionText = "color is ${genericName}"
if (device.currentValue('colorName') != genericName && settings.txtEnable) {
log.info descriptionText
}
sendEvent(name: 'colorName', value: genericName, descriptionText: descriptionText)
}
/**
* Send 'effectName' attribute event
* @param rawValue raw effect attribute value (optional, if not provided, 'none' will be used)
*/
private void sendEffectNameEvent(final String rawValue = null) {
String effectName = 'none'
if (rawValue != null) {
final int effect = hexStrToUnsignedInt(rawValue)
effectName = HueEffectNames[effect] ?: 'unknown'
}
final String descriptionText = "effect was set to ${effectName}"
if (device.currentValue('effectName') != effectName && settings.txtEnable) {
log.info descriptionText
}
sendEvent(name: 'effectName', value: effectName, descriptionText: descriptionText)
}
/**
* Send 'healthStatus' attribute event
* @param status health status
*/
private void sendHealthStatusEvent(final String status) {
if (device.currentValue('healthStatus') != status) {
final String descriptionText = "healthStatus was set to ${status}"
sendEvent(name: 'healthStatus', value: status, descriptionText: descriptionText)
if (settings.txtEnable) {
log.info descriptionText
}
}
}
/**
* Send 'level' attribute event
* @param rawValue raw level attribute value
*/
private void sendLevelEvent(final Object rawValue) {
final BigDecimal value = rawValue as BigDecimal
final int level = Math.round(value / 2.54).intValue()
final String descriptionText = "level was set to ${level}%"
if (device.currentValue('level') as Integer != level && settings.txtEnable) {
log.info descriptionText
}
sendEvent(name: 'level', value: level, descriptionText: descriptionText, unit: '%')
}
/**
* Send 'switch' attribute event
* @param isOn true if light is on, false otherwise
*/
private void sendSwitchEvent(final Boolean isOn) {
final String value = isOn ? 'on' : 'off'
final String descriptionText = "light was turned ${value}"
if (device.currentValue('switch') != value && settings.txtEnable) {
log.info descriptionText
}
sendEvent(name: 'switch', value: value, descriptionText: descriptionText)
}
/**
* Send 'switchLevel' attribute event
* @param isOn true if light is on, false otherwise
* @param level brightness level (0-254)
*/
private List setLevelPrivate(final Object value, final Integer rate = 0, final Integer delay = 0, final Boolean levelPreset = false) {
List cmds = []
final Integer level = constrain(value)
final String hexLevel = DataType.pack(Math.round(level * 2.54).intValue(), DataType.UINT8)
final String hexRate = DataType.pack(rate, DataType.UINT16, true)
final int levelCommand = levelPreset ? 0x00 : 0x04
if (device.currentValue('switch') == 'off' && level > 0 && levelPreset == false) {
// If light is off, first go to level 0 then to desired level
cmds += zigbee.command(zigbee.LEVEL_CONTROL_CLUSTER, 0x00, [destEndpoint:safeToInt(getDestinationEP())], delay, "00 0000 ${PRE_STAGING_OPTION}")
}
// Payload: Level | Transition Time | Options Mask | Options Override
// Options: Bit 0x01 enables pre-staging level
cmds += zigbee.command(zigbee.LEVEL_CONTROL_CLUSTER, levelCommand, [destEndpoint:safeToInt(getDestinationEP())], delay, "${hexLevel} ${hexRate} ${PRE_STAGING_OPTION}") +
ifPolling(DELAY_MS + (rate * 100)) { zigbee.levelRefresh(0) }
return cmds
}
// Command timeout before setting healthState to offline
@Field static final int COMMAND_TIMEOUT = 10
// Delay in between zigbee commands
@Field static final int DELAY_MS = 200
// Hue hub uses five minute attribute reporting
@Field static final int REPORTING_MAX = 600
// Delay before reading attribute (when using polling)
@Field static final int POLL_DELAY_MS = 1000
// Command option that enable changes when off
@Field static final String PRE_STAGING_OPTION = '01 01'
// Philips Hue private cluster vendor code
@Field static final int PHILIPS_VENDOR = 0x100B
// Philips Hue private cluster
@Field static final int PHILIPS_PRIVATE_CLUSTER = 0xFC03
// Zigbee Attribute IDs
@Field static final int FIRMWARE_VERSION_ID = 0x4000
@Field static final int HUE_PRIVATE_STATE_ID = 0x02
@Field static final int PING_ATTR_ID = 0x01
@Field static final int POWER_RESTORE_ID = 0x4003
@Field static final Map HueEffectNames = [
0x01: 'candle',
0x02: 'fireplace',
0x03: 'color loop',
0x09: 'sunrise',
0x0a: 'sparkle'
]
@Field static final Map IdentifyEffectNames = [
0x00: 'Blink',
0x02: 'Green (1s)',
0x0b: 'Orange (8s)',
0x01: 'Pulse (15s)'
]
@Field static final Map OffModeOpts = [
defaultValue: 0x00,
options : [0x00: 'Fade Off (800ms)', 0x01: 'Instant Off', 0xFF: 'Dim to Zero']
]
@Field static final Map PowerRestoreOpts = [
defaultValue: 0xFF,
options : [0x00: 'Off', 0x01: 'On', 0xFF: 'Last State']
]
@Field static final Map TransitionOpts = [
defaultValue: 0x0004,
options : [
0x0000: 'No Delay',
0x0002: '200ms',
0x0004: '400ms',
0x000A: '1s',
0x000F: '1.5s',
0x0014: '2s',
0x001E: '3s',
0x0028: '4s',
0x0032: '5s',
0x0064: '10s'
]
]
@Field static final Map HealthcheckIntervalOpts = [
defaultValue: 10,
options : [10: 'Every 10 Mins', 15: 'Every 15 Mins', 30: 'Every 30 Mins', 45: 'Every 45 Mins', '59': 'Every Hour', '00': 'Disabled']
]
@Field static final Map LevelRateOpts = [
defaultValue: 0x64,
options : [0xFF: 'Device Default', 0x16: 'Very Slow', 0x32: 'Slow', 0x64: 'Medium', 0x96: 'Medium Fast', 0xC8: 'Fast']
]
@Field static final Map ZigbeeStatusEnum = [
0x00: 'Success',
0x01: 'Failure',
0x02: 'Not Authorized',
0x80: 'Malformed Command',
0x81: 'Unsupported COMMAND',
0x85: 'Invalid Field',
0x86: 'Unsupported Attribute',
0x87: 'Invalid Value',
0x88: 'Read Only',
0x89: 'Insufficient Space',
0x8A: 'Duplicate Exists',
0x8B: 'Not Found',
0x8C: 'Unreportable Attribute',
0x8D: 'Invalid Data Type',
0x8E: 'Invalid Selector',
0x94: 'Time out',
0x9A: 'Notification Pending',
0xC3: 'Unsupported Cluster'
]
@Field static final Map ZdoClusterEnum = [
0x0013: 'Device announce',
0x8004: 'Simple Descriptor Response',
0x8005: 'Active Endpoints Response',
0x801D: 'Extended Simple Descriptor Response',
0x801E: 'Extended Active Endpoint Response',
0x8021: 'Bind Response',
0x8022: 'Unbind Response',
0x8023: 'Bind Register Response',
]
@Field static final Map ZigbeeGeneralCommandEnum = [
0x00: 'Read Attributes',
0x01: 'Read Attributes Response',
0x02: 'Write Attributes',
0x03: 'Write Attributes Undivided',
0x04: 'Write Attributes Response',
0x05: 'Write Attributes No Response',
0x06: 'Configure Reporting',
0x07: 'Configure Reporting Response',
0x08: 'Read Reporting Configuration',
0x09: 'Read Reporting Configuration Response',
0x0A: 'Report Attributes',
0x0B: 'Default Response',
0x0C: 'Discover Attributes',
0x0D: 'Discover Attributes Response',
0x0E: 'Read Attributes Structured',
0x0F: 'Write Attributes Structured',
0x10: 'Write Attributes Structured Response',
0x11: 'Discover Commands Received',
0x12: 'Discover Commands Received Response',
0x13: 'Discover Commands Generated',
0x14: 'Discover Commands Generated Response',
0x15: 'Discover Attributes Extended',
0x16: 'Discover Attributes Extended Response'
]
@Field static final Map HueColorScenes = [
'Savanna Sunset' : [
'brightness': 200,
'hue' : 14.717,
'saturation': 83.137
],
'Tropical Twilight': [
'brightness': 123,
'hue' : 263.182,
'saturation': 34.51
],
'Arctic Aurora' : [
'brightness': 138,
'hue' : 201.308,
'saturation': 83.922
],
'Spring Blossom' : [
'brightness': 215,
'hue' : 339.718,
'saturation': 27.843
],
'Relax' : [
'brightness': 145,
'hue' : 36.568,
'saturation': 66.275
],
'Read' : [
'brightness': 255,
'hue' : 38.88,
'saturation': 49.02
],
'Concentrate' : [
'brightness': 255,
'hue' : 45,
'saturation': 21.961
],
'Energize' : [
'brightness': 255,
'hue' : 173.333,
'saturation': 3.529
],
'Bright' : [
'brightness': 255,
'hue' : 38.667,
'saturation': 52.941
],
'Dimmed' : [
'brightness': 77,
'hue' : 38.222,
'saturation': 52.941
],
'Nightlight' : [
'brightness': 1,
'hue' : 33.767,
'saturation': 84.314
]
]
/*
-----------------------------------------------------------------------------
kkossev drivers commonly used functions
-----------------------------------------------------------------------------
*/
Integer safeToInt(val, Integer defaultVal=0) {
return "${val}"?.isInteger() ? "${val}".toInteger() : defaultVal
}
Double safeToDouble(val, Double defaultVal=0.0) {
return "${val}"?.isDouble() ? "${val}".toDouble() : defaultVal
}
void sendZigbeeCommands(ArrayList cmd) {
logDebug "${device.displayName} sendZigbeeCommands(cmd=$cmd)"
hubitat.device.HubMultiAction allActions = new hubitat.device.HubMultiAction()
cmd.each {
allActions.add(new hubitat.device.HubAction(it, hubitat.device.Protocol.ZIGBEE))
}
sendHubCommand(allActions)
}
def driverVersionAndTimeStamp() {version() + ' ' + timeStamp() + ((debug==true) ? " debug version!" : " ")}
def getDeviceInfo() {
return "model=${device.getDataValue('model')} manufacturer=${device.getDataValue('manufacturer')} destinationEP=${state.destinationEP ?: UNKNOWN} deviceProfile=${state.deviceProfile ?: UNKNOWN}"
}
def getDestinationEP() { // [destEndpoint:safeToInt(getDestinationEP())]
return state.destinationEP ?: device.endpointId ?: "01"
}
def checkDriverVersion() {
if (state.driverVersion != null && driverVersionAndTimeStamp() == state.driverVersion) {
// no driver version change
}
else {
logDebug "${device.displayName} updating the settings from the current driver version ${state.driverVersion} to the new version ${driverVersionAndTimeStamp()}"
state.driverVersion = driverVersionAndTimeStamp()
}
}
// called from configure()
def setDestinationEP() {
def ep = device.getEndpointId()
if (ep != null && ep != 'F2') {
state.destinationEP = ep
logDebug "setDestinationEP() destinationEP = ${state.destinationEP}"
}
else {
logWarn "setDestinationEP() Destination End Point not found or invalid(${ep}), activating the F2 bug patch!"
state.destinationEP = "01" // fallback EP
}
}
def logDebug(msg) {
if (settings.debugEnable) {
log.debug "${device.displayName} " + msg
}
}
def logInfo(msg) {
if (settings.infoEnable) {
log.info "${device.displayName} " + msg
}
}
def logWarn(msg) {
if (settings.debugEnable) {
log.warn "${device.displayName} " + msg
}
}
/*
-----------------------------------------------------------------------------
Tuya cluster EF00 specific code
-----------------------------------------------------------------------------
*/
private getCLUSTER_TUYA() { 0xEF00 }
private getSETDATA() { 0x00 }
private getSETTIME() { 0x24 }
// Tuya Commands
private getTUYA_REQUEST() { 0x00 }
private getTUYA_REPORTING() { 0x01 }
private getTUYA_QUERY() { 0x02 }
private getTUYA_STATUS_SEARCH() { 0x06 }
private getTUYA_TIME_SYNCHRONISATION() { 0x24 }
// tuya DP type
private getDP_TYPE_RAW() { "01" } // [ bytes ]
private getDP_TYPE_BOOL() { "01" } // [ 0/1 ]
private getDP_TYPE_VALUE() { "02" } // [ 4 byte value ]
private getDP_TYPE_STRING() { "03" } // [ N byte string ]
private getDP_TYPE_ENUM() { "04" } // [ 0-255 ]
private getDP_TYPE_BITMAP() { "05" } // [ 1,2,4 bytes ] as bits
private sendTuyaCommand(dp, dp_type, fncmd) {
ArrayList cmds = []
def ep = safeToInt(state.destinationEP)
if (ep==null || ep==0) ep = 1
cmds += zigbee.command(CLUSTER_TUYA, SETDATA, [destEndpoint :ep], PACKET_ID + dp + dp_type + zigbee.convertToHexString((int)(fncmd.length()/2), 4) + fncmd )
logDebug "${device.displayName} sendTuyaCommand = ${cmds}"
return cmds
}
private getPACKET_ID() {
return randomPacketId()
}
def tuyaBlackMagic() {
def ep = safeToInt(state.destinationEP ?: 01)
if (ep==null || ep==0) ep = 1
return zigbee.readAttribute(0x0000, [0x0004, 0x000, 0x0001, 0x0005, 0x0007, 0xfffe], [destEndpoint :ep], delay=200)
}