/* groovylint-disable CompileStatic, CouldBeSwitchStatement, DuplicateNumberLiteral, DuplicateStringLiteral, ImplicitClosureParameter, ImplicitReturnStatement, LineLength, MethodCount, MethodParameterTypeRequired, NoDef, NoDouble, PublicMethodsBeforeNonPublicMethods, StaticMethodsBeforeInstanceMethods, UnnecessaryGetter, UnnecessaryObjectReferences, UnnecessarySetter */
/**
* Matter test - Device Driver for Hubitat Elevation
*
* https://community.hubitat.com/t/dynamic-capabilities-commands-and-attributes-for-drivers/98342
*
* 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.
*
* Thanks to Hubitat for publishing the sample Matter driver https://github.com/hubitat/HubitatPublic/blob/master/examples/drivers/thirdRealityMatterNightLight.groovy
*
* ver. 1.0.0 2023-12-21 kkossev - Inital version: added healthCheck attribute; added refresh(); added stats; added RTT attribute; added periodicPolling healthCheck method;
* ver. 1.0.2 2023-12-26 kkossev - commented out the initialize() and configure() capabilities because of duplicated subscriptions; added getInfo command; fixed the refresh() command for MATTER_OUTLET; added isDigital isRefresh; use the Basic cluster attr. 0 for ping()
* ver. 1.0.3 2023-12-28 kkossev - added info for ColorControl and LevelControl clusters; added toggle(); added initializeCtr and duplicatedCtr in stats; added reSubscribe() method
* ver. 1.0.4 2024-01-23 kkossev - added spammyReportsFilter preference;
* ver. 1.1.0 2024-08-15 mavvrick - Add parsing for Color Temp command
* ver. 1.1.1 2024-08-18 kkossev - merged the changes from the dev. branch to the master branch
*
* TODO:
* TODO: add flashRate preference; add flash() command
* TODO: add silentMode attribute
* TODO: add flashOnce()
* TODO: add powerOnBehavior
* TODO: add state.color w/ min/max Mirad values
*/
static String version() { '1.1.1' }
static String timeStamp() { '2024/08/18 9:04 AM' }
@Field static final Boolean _DEBUG = false
@Field static final String DEVICE_TYPE = 'MATTER_BULB'
@Field static final Integer DIGITAL_TIMER = 3000 // command was sent by this driver
@Field static final Integer REFRESH_TIMER = 6000 // refresh time in miliseconds
@Field static final Integer INFO_AUTO_CLEAR_PERIOD = 60 // automatically clear the Info attribute after 60 seconds
@Field static final Integer COMMAND_TIMEOUT = 10 // timeout time in seconds
@Field static final Integer MAX_PING_MILISECONDS = 10000 // rtt more than 10 seconds will be ignored
@Field static final Integer PRESENCE_COUNT_THRESHOLD = 3 // missing 3 checks will set the device healthStatus to offline
@Field static final String UNKNOWN = 'UNKNOWN'
import groovy.transform.Field
import hubitat.helper.HexUtils
metadata {
definition(name: 'Matter Advanced RGBW Light', namespace: 'kkossev', author: 'Krassimir Kossev', singleThreaded: true) {
capability 'Actuator'
capability 'Switch'
capability 'SwitchLevel'
capability 'Color Control'
capability "ColorTemperature"
capability 'Light'
capability 'Initialize'
capability 'Refresh'
capability 'Health Check'
attribute 'healthStatus', 'enum', ['unknown', 'offline', 'online']
attribute 'rtt', 'number'
attribute 'Status', 'string'
attribute 'silentMode', 'enum', ['off', 'on'] // disable all logging and events while in color animation mode
command 'getInfo'
command 'toggle'
command 'identify'
command 'initialize', [[name: 'Invoked automatically during the hub reboot, do not click!']]
command 'reSubscribe', [[name: 're-subscribe to the Matter controller events']]
if (_DEBUG) {
command 'test', [[name: 'test', type: 'STRING', description: 'test', defaultValue : '']]
command 'parseTest', [[name: 'parseTest', type: 'STRING', description: 'parseTest', defaultValue : '']]
}
// fingerprints are commented out, because are already included in the stock driver
// fingerprint endpointId:'01', inClusters:'0003,0004,0005,0006,0008,001D,001E,0300', outClusters:'', model:'NL67', manufacturer:'Nanoleaf', controllerType:'MAT' // Nanoleaf Essentials A19 Bulb
// fingerprint endpointId:'01', inClusters:'0003,0004,0005,0006,0008,001D,001E,0300', outClusters:'', model:'NL68', manufacturer:'Nanoleaf', controllerType:'MAT' // Nanoleaf Strip 5E8
}
preferences {
input(name:'txtEnable', type:'bool', title:'Enable descriptionText logging', defaultValue:true)
input(name:'logEnable', type:'bool', title:'Enable debug logging', defaultValue:true)
input(name:'transitionTime', type:'enum', title:"Level transition time (default:${ttOpts.defaultText})", options:ttOpts.options, defaultValue:ttOpts.defaultValue)
input(name:'rgbTransitionTime', type:'enum', title:"RGB transition time (default:${ttOpts.defaultText})", options:ttOpts.options, defaultValue:ttOpts.defaultValue)
input name: 'advancedOptions', type: 'bool', title: 'Advanced Options', description: 'These advanced options should be already automatically set in an optimal way for your device...', defaultValue: false
if (advancedOptions == true || advancedOptions == true) {
input name: 'spammyReportsFilter', type: 'enum', title: 'Spammy Reports Filtering', options: SpammyReportsFilterOpts.options, defaultValue: SpammyReportsFilterOpts.defaultValue, required: true, description: 'Filtering spammy reports.'
input name: 'healthCheckMethod', type: 'enum', title: 'Healthcheck Method', options: HealthcheckMethodOpts.options, defaultValue: HealthcheckMethodOpts.defaultValue, required: true, description: 'Method to check device online/offline status.'
input name: 'healthCheckInterval', type: 'enum', title: 'Healthcheck Interval', options: HealthcheckIntervalOpts.options, defaultValue: HealthcheckIntervalOpts.defaultValue, required: true, description: 'How often the hub will check the device health.
3 consecutive failures will result in status "offline"'
}
}
}
@Field static final Map HealthcheckMethodOpts = [ // used by healthCheckMethod
defaultValue: 1,
options : [0: 'Disabled', 1: 'Activity check', 2: 'Periodic polling']
]
@Field static final Map HealthcheckIntervalOpts = [ // used by healthCheckInterval
defaultValue: 240,
options : [10: 'Every 10 Mins', 30: 'Every 30 Mins', 60: 'Every 1 Hour', 240: 'Every 4 Hours', 720: 'Every 12 Hours']
]
@Field static final Map StartUpOnOffEnumOpts = [0: 'Off', 1: 'On', 2: 'Toggle']
@Field static final Map SpammyReportsFilterOpts = [ // delay spammy reports
defaultValue: 500,
options : [0: 'none', 250: '250 ms', 500: '500 ms', 750: '750 ms', 1000: '1000 ms', 1500: '1500 ms', 2000: '2000 ms', 3000: '3000 ms', 5000: '5000 ms']
]
//transitionTime options
@Field static Map ttOpts = [
defaultValue: '1',
defaultText: '1s',
options:['0':'ASAP', '1':'1s', '2':'2s', '5':'5s']
]
@Field static Map colorRGBName = [
4: 'Red',
13:'Orange',
21:'Yellow',
29:'Chartreuse',
38:'Green',
46:'Spring',
54:'Cyan',
63:'Azure',
71:'Blue',
79:'Violet',
88:'Magenta',
96:'Rose',
101:'Red'
]
//parsers
void parse(String description) {
checkDriverVersion()
if (state.stats != null) { state.stats['rxCtr'] = (state.stats['rxCtr'] ?: 0) + 1 } else { state.stats = [:] }
if (state.lastRx != null) { state.lastRx['checkInTime'] = new Date().getTime() } else { state.lastRx = [:] }
checkSubscriptionStatus()
unschedule('deviceCommandTimeout')
setHealthStatusOnline()
Map descMap
try {
descMap = matter.parseDescriptionAsMap(description)
} catch (e) {
logWarn "parse: exception ${e}
Failed to parse description: ${description}"
return
}
logDebug "parse: descMap:${descMap} description:${description}"
if (descMap == null) {
logWarn "parse: descMap is null description:${description}"
return
}
if (descMap.attrId == 'FFFB') { // parse the AttributeList first!
pareseAttributeList(descMap)
return
}
switch (descMap.cluster) {
case '0000' :
if (descMap.attrId == '4000') { //software build ?
updateDataValue('softwareBuild', descMap.value ?: 'unknown')
}
else {
logWarn "skipped softwareBuild, attribute:${descMap.attrId}, value:${descMap.value}"
}
break
case '0003' : // Identify
gatherAttributesValuesInfo(descMap, IdentifyClusterAttributes)
break
case '0004' : // Groups
gatherAttributesValuesInfo(descMap, GroupsClusterAttributes)
break
case '0005' : // Scenes
gatherAttributesValuesInfo(descMap, ScenesClusterAttributes)
case '0006' : // On/Off Cluster
gatherAttributesValuesInfo(descMap, OnOffClusterAttributes)
parseOnOffCluster(descMap)
break
case '0008' : // LevelControl
if (descMap.attrId == '0000') { //current level
sendLevelEvent(descMap.value)
}
else {
logWarn "skipped level, attribute:${descMap.attrId}, value:${descMap.value}"
}
gatherAttributesValuesInfo(descMap, LevelControlClusterAttributes)
break
case '001D' : // Descriptor, ep:00
gatherAttributesValuesInfo(descMap, DescriptorClusterAttributes)
break
case '002F' : // PowerSource, ep:02 // parse: descMap:[endpoint:02, cluster:002F, attrId:000C, value:C8, clusterInt:47, attrInt:12] description:read attr - endpoint: 02, cluster: 002F, attrId: 000C, value: 04C8
parseBatteryEvent(descMap)
gatherAttributesValuesInfo(descMap, PowerSourceClusterAttributes)
break
case '0028' : // BasicInformation, ep:00
gatherAttributesValuesInfo(descMap, BasicInformationClusterAttributes)
break
case '0045' : // BooleanState
gatherAttributesValuesInfo(descMap, BoleanStateClusterAttributes)
parseContactEvent(descMap)
break
case '0300' : // ColorControl
if (descMap.attrId == '0000') { //hue
sendHueEvent(descMap.value)
} else if (descMap.attrId == '0001') { //saturation
sendSaturationEvent(descMap.value)
}
else if (descMap.attrId == '0007') { //color temperature
logDebug "parse: skipped color temperature:${descMap}"
}
else if (descMap.attrId == "0007") { //color temp
sendCTEvent(descMap.value)
} //logDebug "parse: skipped color temperature:${descMap}"
else if (descMap.attrId == '0008') { //color mode
logDebug "parse: skipped color mode:${descMap}"
}
else {
logWarn "parse: skipped color, attribute:${descMap.attrId}, value:${descMap.value}"
}
gatherAttributesValuesInfo(descMap, ColorControlClusterAttributes)
break
default :
logWarn "parse: skipped:${descMap}"
}
}
// AttributeList 0xFFFB
void pareseAttributeList(final Map descMap) {
logDebug "pareseAttributeList: descMap:${descMap}"
Integer cluster = descMap.clusterInt as Integer
String stateName = '0x' + HexUtils.integerToHexString(cluster, 2)
if (state.matter == null) { state.matter = [:] }
state.matter[stateName] = descMap.value
logDebug "pareseAttributeList: state.matter[$stateName] = ${descMap.value}"
}
void gatherAttributesValuesInfo(final Map descMap, final Map knownClusterAttributes) {
Integer attrInt = descMap.attrInt as Integer
String attrName = knownClusterAttributes[attrInt]
Integer tempIntValue
String tmpStr
if (attrName == null) {
attrName = GlobalElementsAttributes[attrInt]
}
//logDebug "gatherAttributesValuesInfo: cluster:${descMap.cluster} attrInt:${attrInt} attrName:${attrName} value:${descMap.value}"
if (attrName == null) {
logWarn "gatherAttributesValuesInfo: unknown attribute # ${attrInt}"
return
}
if (state.states['isInfo'] == true) {
logDebug "gatherAttributesValuesInfo: isInfo:${state.states['isInfo']} state.states['cluster'] = ${state.states['cluster']} "
if (state.states['cluster'] == descMap.cluster) {
if (descMap.value != null && descMap.value != '') {
tmpStr = "[${descMap.attrId}] ${attrName}"
if (tmpStr in state.tmp) {
logWarn "gatherAttributesValuesInfo: tmpStr:${tmpStr} is already in the state.tmp"
return
}
try {
tempIntValue = HexUtils.hexStringToInt(descMap.value)
if (tempIntValue >= 10) {
tmpStr += ' = 0x' + descMap.value + ' (' + tempIntValue + ')'
}
else {
tmpStr += ' = ' + descMap.value
}
} catch (e) {
tmpStr += ' = ' + descMap.value
}
if (logEnable) { logInfo "$tmpStr" }
state.tmp = (state.tmp ?: '') + "${tmpStr} " + '
'
}
}
}
else if ((state.states['isPing'] ?: false) == true && descMap.cluster == '0028' && descMap.attrId == '0000') {
Long now = new Date().getTime()
Integer timeRunning = now.toInteger() - (state.lastTx['pingTime'] ?: '0').toInteger()
if (timeRunning > 0 && timeRunning < MAX_PING_MILISECONDS) {
state.stats['pingsOK'] = (state.stats['pingsOK'] ?: 0) + 1
if (timeRunning < safeToInt((state.stats['pingsMin'] ?: '999'))) { state.stats['pingsMin'] = timeRunning }
if (timeRunning > safeToInt((state.stats['pingsMax'] ?: '0'))) { state.stats['pingsMax'] = timeRunning }
state.stats['pingsAvg'] = approxRollingAverage(safeToDouble(state.stats['pingsAvg']), safeToDouble(timeRunning)) as int
sendRttEvent()
}
else {
logWarn "unexpected ping timeRunning=${timeRunning} "
}
state.states['isPing'] = false
}
/* groovylint-disable-next-line EmptyElseBlock */
else {
//logDebug "gatherAttributesValuesInfo: isInfo:${state.states['isInfo']} descMap:${descMap}"
}
}
void parseOnOffCluster(Map descMap) {
logDebug "parseOnOffCluster: descMap:${descMap}"
if (descMap.cluster != '0006') {
logWarn "parseOnOffCluster: unexpected cluster:${descMap.cluster} (attrId:${descMap.attrId})"
return
}
Integer attrInt = descMap.attrInt as Integer
Integer value
//String descriptionText = ''
//Map eventMap = [:]
String attrName = OnOffClusterAttributes[attrInt] ?: GlobalElementsAttributes[attrInt] ?: UNKNOWN
switch (descMap.attrId) {
case '0000' : // Switch
sendSwitchEvent(descMap.value)
break
case '4000' : // GlobalSceneControl
if (logEnable) { logInfo "parse: Switch: GlobalSceneControl = ${descMap.value}" }
if (state.onOff == null) { state.onOff = [:] } ; state.onOff['GlobalSceneControl'] = descMap.value
break
case '4001' : // OnTime
if (logEnable) { logInfo "parse: Switch: OnTime = ${descMap.value}" }
if (state.onOff == null) { state.onOff = [:] } ; state.onOff['OnTime'] = descMap.value
break
case '4002' : // OffWaitTime
if (logEnable) { logInfo "parse: Switch: OffWaitTime = ${descMap.value}" }
if (state.onOff == null) { state.onOff = [:] } ; state.onOff['OffWaitTime'] = descMap.value
break
case '4003' : // StartUpOnOff
value = descMap.value as int
String startUpOnOffText = "parse: Switch: StartUpOnOff = ${descMap.value} (${StartUpOnOffEnumOpts[value] ?: UNKNOWN})"
if (logEnable) { logInfo "${startUpOnOffText}" }
if (state.onOff == null) { state.onOff = [:] } ; state.onOff['StartUpOnOff'] = descMap.value
break
case ['FFF8', 'FFF9', 'FFFA', 'FFFB', 'FFFC', 'FFFD', '00FE'] :
if (logEnable) {
logInfo "parse: Switch: ${attrName} = ${descMap.value}"
}
break
default :
logWarn "parseOnOffCluster: unexpected attrId:${descMap.attrId} (raw:${descMap.value})"
}
}
//events
private void sendSwitchEvent(String rawValue, isDigital = false) {
String value = rawValue == '01' ? 'on' : 'off'
String descriptionText = "bulb was turned ${value}"
Map eventMap = [name: 'switch', value: value, descriptionText: descriptionText, type: isDigital ? 'digital' : 'physical']
if (state.states['isRefresh'] == true) {
eventMap.descriptionText = "bulb is ${value} [refresh]"
eventMap.isStateChange = true // force the event to be sent
}
if (device.currentValue('switch') == value && state.states['isRefresh'] != true) {
logDebug "ignored duplicated switch event, value:${value}"
return
}
eventMap.descriptionText += (isDigital == true || state.states['isDigital'] == true ) ? ' [digital]' : ' [physical]'
logInfo "${eventMap.descriptionText}"
sendEvent(eventMap)
}
private void sendLevelEvent(String rawValue) {
Integer value = Math.round(hexStrToUnsignedInt(rawValue) / 2.55)
if (value == 0 || value == device.currentValue('level')) { return }
Map eventMap = [name: 'level', value: value, descriptionText: "level was set to ${value}%", unit: '%']
if (state.states['isRefresh'] == true) {
eventMap.descriptionText = "level is ${value}% [refresh]"
eventMap.isStateChange = true // force the event to be sent
}
Object latestEvent = device.latestState('level', skipCache=true)
int latestEventTime = latestEvent != null ? latestEvent.getDate().getTime() : now()
int timeDiff = (now() - latestEventTime) as int
if (settings.spammyReportsFilter == null || (settings.spammyReportsFilter as int) == 0 || timeDiff > (settings.spammyReportsFilter as int)) {
// send it now!
unschedule('sendDelayedLevelEvent')
sendDelayedLevelEvent(eventMap)
}
else {
int delayedTime = (settings?.spammyReportsFilter as int) - timeDiff
eventMap.delayed = delayedTime
eventMap.descriptionText += " [delayed ${eventMap.delayed} ms]"
logDebug "this level event (${eventMap.value}%) will be delayed ${delayedTime} ms"
runInMillis(delayedTime, 'sendDelayedLevelEvent', [overwrite: true, data: eventMap])
}
}
private void sendDelayedLevelEvent(Map eventMap) {
logInfo "${eventMap.descriptionText}"
//map.each {log.trace "$it"}
sendEvent(eventMap)
}
/* groovylint-disable-next-line UnusedPrivateMethodParameter */
private void sendHueEvent(String rawValue, Boolean presetColor = false) {
Integer value = hex254ToInt100(rawValue)
if (value == device.currentValue('hue')) { return }
sendRGBNameEvent(value)
Map eventMap = [name: 'hue', value: value, descriptionText: "hue was set to ${value}%", unit: '%']
if (state.states['isRefresh'] == true) {
eventMap.descriptionText += ' [refresh]'
eventMap.isStateChange = true // force the event to be sent
}
Object latestEvent = device.latestState('hue', skipCache = true)
int latestEventTime = latestEvent != null ? latestEvent.getDate().getTime() : now()
int timeDiff = (now() - latestEventTime) as int
if (settings.spammyReportsFilter == null || (settings.spammyReportsFilter as int) == 0 || timeDiff > (settings.spammyReportsFilter as int)) {
// send it now!
unschedule('sendDelayedHueEvent')
sendDelayedHueEvent(eventMap)
}
else {
int delayedTime = (settings?.spammyReportsFilter as int) - timeDiff
eventMap.delayed = delayedTime
eventMap.descriptionText += " [delayed ${eventMap.delayed} ms]"
logDebug "this hue event (${eventMap.value}) will be delayed ${delayedTime} ms"
runInMillis(delayedTime, 'sendDelayedHueEvent', [overwrite: true, data: eventMap])
}
}
private void sendDelayedHueEvent(Map eventMap) {
logInfo "${eventMap.descriptionText}"
sendEvent(eventMap)
}
/* groovylint-disable-next-line UnusedPrivateMethodParameter */
private void sendSaturationEvent(String rawValue, Boolean presetColor = false) {
Integer value = hex254ToInt100(rawValue)
if (value == device.currentValue('saturation')) { return }
sendRGBNameEvent(null, value)
Map eventMap = [name: 'saturation', value: value, descriptionText: "saturation was set to ${value}%", unit: '%']
if (state.states['isRefresh'] == true) {
eventMap.descriptionText += ' [refresh]'
eventMap.isStateChange = true // force the event to be sent
}
Object latestEvent = device.latestState('saturation', skipCache = true)
int latestEventTime = latestEvent != null ? latestEvent.getDate().getTime() : now()
int timeDiff = (now() - latestEventTime) as int
if (settings.spammyReportsFilter == null || (settings.spammyReportsFilter as int) == 0 || timeDiff > (settings.spammyReportsFilter as int)) {
// send it now!
unschedule('sendDelayedSaturationEvent')
sendDelayedSaturationEvent(eventMap)
}
else {
int delayedTime = (settings?.spammyReportsFilter as int) - timeDiff
eventMap.delayed = delayedTime
eventMap.descriptionText += " [delayed ${eventMap.delayed} ms]"
logDebug "this saturation event (${eventMap.value}) will be delayed ${delayedTime} ms"
runInMillis(delayedTime, 'sendDelayedSaturationEvent', [overwrite: true, data: eventMap])
}
}
private void sendDelayedSaturationEvent(Map eventMap) {
logInfo "${eventMap.descriptionText}"
sendEvent(eventMap)
}
/* groovylint-disable-next-line UnusedPrivateMethodParameter */
private void sendCTEvent(String rawValue, Boolean presetColor = false) {
Integer value = (1000000/(hexStrToUnsignedInt(rawValue))).toInteger()
String descriptionText = "${device.displayName} ColorTemp was set to ${value}K"
if (txtEnable) { log.info descriptionText }
sendEvent(name:"colorTemperature", value:value, descriptionText:descriptionText, unit: "K")
}
/* groovylint-disable-next-line MethodParameterTypeRequired, NoDef, UnusedPrivateMethodParameter */
private void sendRGBNameEvent(hue, sat = null) {
String genericName
if (device.currentValue('saturation') == 0) {
genericName = 'White'
} else if (hue == null) {
return
} else {
genericName = colorRGBName.find { k , v -> hue < k }.value
}
if (genericName == device.currentValue('colorName')) { return }
Map eventMap = [name: 'colorName', value: genericName, descriptionText: "color is ${genericName}"]
if (state.states['isRefresh'] == true) {
eventMap.descriptionText += ' [refresh]'
eventMap.isStateChange = true // force the event to be sent
}
Object latestEvent = device.latestState('colorName', skipCache = true)
int latestEventTime = latestEvent != null ? latestEvent.getDate().getTime() : now()
int timeDiff = (now() - latestEventTime) as int
if (settings.spammyReportsFilter == null || (settings.spammyReportsFilter as int) == 0 || timeDiff > (settings.spammyReportsFilter as int)) {
// send it now!
unschedule('sendDelayedRGBNameEvent')
sendDelayedRGBNameEvent(eventMap)
}
else {
int delayedTime = (settings?.spammyReportsFilter as int) - timeDiff
eventMap.delayed = delayedTime
eventMap.descriptionText += " [delayed ${eventMap.delayed} ms]"
logDebug "this colorName event (${eventMap.value}) will be delayed ${delayedTime} ms"
runInMillis(delayedTime, 'sendDelayedRGBNameEvent', [overwrite: true, data: eventMap])
}
}
private void sendDelayedRGBNameEvent(Map eventMap) {
logInfo "${eventMap.descriptionText}"
sendEvent(eventMap)
}
//capability commands
void on() {
logDebug 'switching on()'
setDigitalRequest() // 3 seconds
sendToDevice(matter.on())
}
void off() {
logDebug 'switching off()'
setDigitalRequest()
sendToDevice(matter.off())
}
void toggle() {
logDebug 'toggling...'
setDigitalRequest()
String cmd = matter.invoke(device.endpointId, 0x0006, 0x0002)
sendToDevice(cmd)
}
void identify() {
logDebug 'identifying...'
String cmd
Integer time = 10
List