/* groovylint-disable CompileStatic, CouldBeSwitchStatement, DuplicateMapLiteral, DuplicateNumberLiteral, DuplicateStringLiteral, ImplicitClosureParameter, ImplicitReturnStatement, LineLength, MethodCount, MethodParameterTypeRequired, MethodSize, 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.1 2023-12-23 kkossev - Inital version; added onOff stats; added toggle(); commented out the initialize() and configure() capabilities because of duplicated subscriptions
* ver. 1.0.2 2023-12-26 kkossev - 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 - (dev. branch) added initializeCtr and duplicatedCtr in stats; added reSubscribe() method
*
* TODO: add power meter handling
* TODO: add flashRate preference; add flash() command
* TODO: add flashOnce()
* TODO: add powerOnBehavior
*/
static String version() { '1.0.3' }
static String timeStamp() { '2023/12/28 9:12 PM' }
@Field static final Boolean _DEBUG = false
@Field static final String DEVICE_TYPE = 'MATTER_OUTLET'
@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 Outlet', namespace: 'kkossev', author: 'Krassimir Kossev') {
capability 'Actuator'
capability 'Sensor'
capability 'Outlet'
capability 'Switch'
capability 'Power Meter'
capability 'Initialize'
capability 'Refresh'
capability 'Health Check'
attribute 'healthStatus', 'enum', ['unknown', 'offline', 'online']
attribute 'rtt', 'number'
attribute 'Status', 'string'
command 'toggle'
command 'getInfo'
//command 'identify' // can't make it work ... :(
//command 'unsubscribe'
//command 'subscribe'
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 : '']]
}
// fingerprints are commented out, because are already included in the stock driver
// fingerprint endpointId:'01', inClusters:'001D,0003,0004,0005,0006', outClusters:'', model:'S4', manufacturer:'Onvis', controllerType:'MAT' // Onvis plug // Onvis Smart Plug SP120
}
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: '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: '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']
//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 '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
default :
logWarn "parse: skipped:${descMap}"
}
}
void parseContactEvent(Map descMap) {
logDebug "parseContactEvent: descMap:${descMap}"
if (descMap.cluster != '0045' || descMap.attrId != '0000') {
logWarn "parseContactEvent: unexpected cluster:${descMap.cluster} or attrId:${descMap.attrId}"
return
}
sendContactEvent(descMap.value)
}
void parseBatteryEvent(Map descMap) {
logDebug "parseBatteryEvent: descMap:${descMap}"
if (descMap.cluster != '002F') {
logWarn "parseBatteryEvent: unexpected cluster:${descMap.cluster} (attrId:${descMap.attrId})"
return
}
Integer value
String descriptionText = ''
Map eventMap = [:]
switch (descMap.attrId) {
case '0000' : // Status
value = HexUtils.hexStringToInt(descMap.value)
descriptionText = "Battery status is: ${PowerSourceClusterStatus[value]} (raw:${descMap.value})"
eventMap = [name: 'batteryStatus', value: PowerSourceClusterStatus[value], descriptionText: descriptionText]
break
case '000E' : // BattChargeLevel
value = HexUtils.hexStringToInt(descMap.value)
descriptionText = "Battery charge level is: ${PowerSourceClusterBatteryChargeLevel[value]} (raw:${descMap.value})"
eventMap = [name: 'batteryChargeLevel', value: PowerSourceClusterBatteryChargeLevel[value], descriptionText: descriptionText]
break
case '000B' : // BatteryVoltage
value = HexUtils.hexStringToInt(descMap.value)
descriptionText = "Battery voltage is: ${value / 1000}V (raw:${descMap.value})"
eventMap = [name: 'batteryVoltage', value: value / 1000, descriptionText: descriptionText]
break
case '000C' : // BatteryPercentageRemaining
value = HexUtils.hexStringToInt(descMap.value)
descriptionText = "Battery percentage remaining is: ${value / 2}% (raw:${descMap.value})"
eventMap = [name: 'battery', value: value / 2, descriptionText: descriptionText]
break
default :
logWarn "parseBatteryEvent: unexpected attrId:${descMap.attrId} (raw:${descMap.value})"
}
if (eventMap != null) {
eventMap.type = 'physical'
eventMap.isStateChange = true
if (state.states['isRefresh'] == true) {
eventMap.descriptionText += ' [refresh]'
}
else {
log.debug "state.states['isRefresh'] = ${state.states['isRefresh']}"
}
sendEvent(eventMap)
logInfo eventMap.descriptionText
}
}
// 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})"
}
/*
if (eventMap != null) {
eventMap.type = 'physical'
eventMap.isStateChange = true
if (state.states['isRefresh'] == true) {
eventMap.descriptionText += ' [refresh]'
}
sendEvent(eventMap)
logInfo eventMap.descriptionText
}
*/
}
//events
private void sendContactEvent(String rawValue, isDigital = false) {
String value = rawValue == '01' ? 'closed' : 'open'
String descriptionText = "contact was ${value}"
Map eventMap = [name: 'contact', value: value, descriptionText: descriptionText, type: isDigital == true ? 'digital' : 'physical']
if (state.states['isRefresh'] == true) {
eventMap.descriptionText = "contact is ${value} [refresh]"
eventMap.isStateChange = true // force the event to be sent
}
if (device.currentValue('contact') == value && state.states['isRefresh'] != true) {
state.stats['duplicatedCtr'] = (state.stats['duplicatedCtr'] ?: 0) + 1
logDebug "ignored duplicated contact event, value:${value}"
return
}
eventMap.descriptionText += (isDigital == true || state.states['isDigital'] == true ) ? ' [digital]' : ' [physical]'
logInfo "${eventMap.descriptionText}"
sendEvent(eventMap)
}
private void sendSwitchEvent(String rawValue, isDigital = false) {
String value = rawValue == '01' ? 'on' : 'off'
String descriptionText = "switch was turned ${value}"
Map eventMap = [name: 'switch', value: value, descriptionText: descriptionText, type: isDigital ? 'digital' : 'physical']
if (state.states['isRefresh'] == true) {
eventMap.descriptionText = "switch 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)
}
//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() {
List