/** * Zigbee Device Drivers for Hubitat * * 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. * * This driver is inspired by @w35l3y work on Tuya device driver (Edge project). * For a big portions of code all credits go to Jonathan Bradshaw. * * ver. 2.0.0 2023-05-08 kkossev - Initial test version (VINDSTYRKA driver) * ver. 2.0.1 2023-05-27 kkossev - another test version (Aqara TVOC Air Monitor driver) * ver. 2.0.2 2023-05-29 kkossev - Just another test version (Aqara E1 thermostat driver) (not ready yet!); added 'Advanced Options'; Xiaomi cluster decoding; added temperatureScale and tVocUnit'preferences; temperature rounding bug fix * ver. 2.0.3 2023-06-10 kkossev - Tuya Zigbee Fingerbot * * TODO: aqaraModel is no saved * TODO: rtt 0 fix * TODO: notPresentCtr bug fix * TODO: store NWK in states * TODO: implement battery level/percentage for Aqara TVOC * TODO: implement Get Device Info command * TODO: 'device' capability * TODO: state timesamps in human readable form * TODO: ad min/max reporting times preferences for temperature and humidity; * TODO - parse the details of the configuration respose - cluster, min, max, delta ... */ static String version() { "2.0.3" } static String timeStamp() {"2023/06/10 7:35 PM"} @Field static final Boolean _DEBUG = false import groovy.transform.Field import hubitat.device.HubMultiAction import hubitat.device.Protocol import hubitat.helper.HexUtils import hubitat.zigbee.zcl.DataType import java.util.concurrent.ConcurrentHashMap /* * To switch between driver types : * 1. Copy this code * 2. From HE 'Drivers Code' select 'New Driver' * 3. Paste the copied code * 4. Comment out the previous device type and un-comment the new device type in the lines defining the: * deviceType * DEVICE_TYPE * name (in the metadata definition section) * 5. Save */ //deviceType = "Device" //@Field static final String DEVICE_TYPE = "Device" //deviceType = "AirQuality" //@Field static final String DEVICE_TYPE = "AirQuality" deviceType = "Fingerbot" @Field static final String DEVICE_TYPE = "Fingerbot" //deviceType = "Thermostat" //@Field static final String DEVICE_TYPE = "Thermostat" //deviceType = "Switch" //@Field static final String DEVICE_TYPE = "Switch" //deviceType = "Dimmer" //@Field static final String DEVICE_TYPE = "Dimmer" //deviceType = "Bulb" //@Field static final String DEVICE_TYPE = "Bulb" //deviceType = "Relay" //@Field static final String DEVICE_TYPE = "Relay" //deviceType = "Plug" //@Field static final String DEVICE_TYPE = "Plug" //deviceType = "MotionSensor" //@Field static final String DEVICE_TYPE = "MotionSensor" //deviceType = "THSensor" //@Field static final String DEVICE_TYPE = "THSensor" metadata { definition ( //name: 'Tuya Zigbee Device', //name: 'VINDSTYRKA Air Quality Monitor', //name: 'Aqara TVOC Air Quality Monitor', name: 'Tuya Zigbee Fingerbot', //name: 'Aqara E1 Thermostat', //name: 'Tuya Zigbee Switch', //name: 'Tuya Zigbee Dimmer', //name: 'Tuya Zigbee Bulb', //name: 'Tuya Zigbee Relay', //name: 'Tuya Zigbee Plug', //importUrl: 'https://raw.githubusercontent.com/kkossev/Hubitat/development/Drivers/Tuya%20Zigbee%20Device%20Driver/Tuya%20Zigbee%20Device.groovy', //importUrl: 'https://raw.githubusercontent.com/kkossev/Hubitat/development/Drivers/Tuya%20Device%20Driver/VINDSTYRKA%20Air%20Quality%20Monitor.groovy', //importUrl: 'https://raw.githubusercontent.com/kkossev/Hubitat/development/Drivers/Aqara%20TVOC%20Air%20Quality%20Monitor/Aqara%20TVOC%20Air%20Quality%20Monitor.groovy', importUrl: 'https://raw.githubusercontent.com/kkossev/Hubitat/development/Drivers/Tuya%20Zigbee%20Fingerbot/Tuya%20Zigbee%20Fingerbot.groovy', //importUrl: 'https://raw.githubusercontent.com/kkossev/Hubitat/development/Drivers/Aqara%20E1%20Thermostat/Aqara%20E1%20Thermostat.groovy', namespace: 'kkossev', author: 'Krassimir Kossev', singleThreaded: true ) { if (_DEBUG) { command 'test', [[name: "test", type: "STRING", description: "test", defaultValue : ""]] command 'parseTest', [[name: "parseTest", type: "STRING", description: "parseTest", defaultValue : ""]] command "tuyaTest", [ [name:"dpCommand", type: "STRING", description: "Tuya DP Command", constraints: ["STRING"]], [name:"dpValue", type: "STRING", description: "Tuya DP value", constraints: ["STRING"]], [name:"dpType", type: "ENUM", constraints: ["DP_TYPE_VALUE", "DP_TYPE_BOOL", "DP_TYPE_ENUM"], description: "DP data type"] ] } // common capabilities for all device types capability "Actuator" capability "Sensor" capability 'Configuration' capability 'Refresh' capability 'Health Check' // common attributes for all device types attribute 'healthStatus', 'enum', ['unknown', 'offline', 'online'] attribute "rtt", "number" attribute "Info", "string" // common commands for all device types command "initialize", [[name: "Manually initialize the device after switching drivers. \n\r ***** Will load device default values! *****"]] // do NOT declare Initialize capability! // deviceType specific capabilities, commands and attributes if (deviceType in ["Device"]) { command "deleteAllSettings", [[name: "Delete All Preferences"]] command "deleteAllCurrentStates", [[name: "Delete All Current States"]] command "deleteAllScheduledJobs", [[name: "Delete All Scheduled Jobs"]] // any scheduled jobs were deleted... command "deleteAllStates", [[name: "Delete All State Variables"]] // state.$it was removed... command "deleteAllChildDevices", [[name: "Delete All Child Devices"]] //command "getInfo", [[name: "Get Fingerprint"]] // TODO if (_DEBUG) { command "getAllProperties", [[name: "Get All Properties"]] } //command "updateFirmware" } if (deviceType in ["Device", "THSensor", "AirQuality", "Thermostat", "Fingerbot"]) { capability "Battery" attribute "batteryVoltage", "number" } if (deviceType in ["Thermostat"]) { capability "ThermostatHeatingSetpoint" } if (deviceType in ["Device", "Switch", "Dimmer", "Fingerbot"]) { capability "Switch" //command "switchCommand" //attribute "switchAttribute", "number" } if (deviceType in ["Dimmer"]) { capability "SwitchLevel" command "switchLevelCommand" attribute "switchAttribute", "number" } if (deviceType in ["Device", "THSensor", "AirQuality", "Thermostat"]) { capability "TemperatureMeasurement" } if (deviceType in ["Device", "THSensor", "AirQuality"]) { capability "RelativeHumidityMeasurement" } if (deviceType in ["AirQuality"]) { capability "AirQuality" // Attributes: airQualityIndex - NUMBER, range:0..500 attribute "pm25", "number" attribute "airQualityLevel", "enum", ["Good","Moderate","Unhealthy for Sensitive Groups","Unhealthy","Very Unhealthy","Hazardous"] // https://www.airnow.gov/aqi/aqi-basics/ } if (deviceType in ["Fingerbot"]) { attribute "fingerbotMode", "enum", FingerbotModeOpts.options.values() as List attribute "direction", "enum", FingerbotDirectionOpts.options.values() as List attribute "pushTime", "number" attribute "dnPosition", "number" attribute "upPosition", "number" } // trap for Hubitat F2 bug fingerprint profileId:"0104", endpointId:"F2", inClusters:"", outClusters:"", model:"unknown", manufacturer:"unknown", deviceJoinName: "Zigbee device affected by Hubitat F2 bug" if (deviceType in ["AirQuality"]) { fingerprint profileId:"0104", endpointId:"01", inClusters:"0000,0003,0004,0402,0405,FC57,FC7C,042A,FC7E", outClusters:"0003,0019,0020,0202", model:"VINDSTYRKA", manufacturer:"IKEA of Sweden", deviceJoinName: "VINDSTYRKA Air Quality Monitor E2112" fingerprint profileId:"0104", endpointId:"01", inClusters:"0000,0003,0500,0001", outClusters:"0019", model:"lumi.airmonitor.acn01", manufacturer:"LUMI", deviceJoinName: "Aqara TVOC Air Quality Monitor" } if (deviceType in ["Thermostat"]) { fingerprint profileId:"0104", endpointId:"01", inClusters:"0000,0001,0003,FCC0,000A,0201", outClusters:"0003,FCC0,0201", model:"lumi.airrtc.agl001", manufacturer:"LUMI", deviceJoinName: "Aqara E1 Thermostat" // model: 'SRTS-A01' } } preferences { 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: true, description: \ 'Turns on debug logging for 24 hours.' if (deviceType in ["AirQuality"]) { if (isVINDSTIRKA()) { input name: 'airQualityIndexCheckInterval', type: 'enum', title: 'Air Quality Index check interval', options: AirQualityIndexCheckIntervalOpts.options, defaultValue: AirQualityIndexCheckIntervalOpts.defaultValue, required: true, description: \ 'Changes how often the hub retreives the Air Quality Index.' } else if (isAqaraTRV()) { input name: 'temperatureScale', type: 'enum', title: 'Temperaure Scale on the Screen', options: TemperatureScaleOpts.options, defaultValue: TemperatureScaleOpts.defaultValue, required: true, description: \ 'Changes the temperature scale (Celsius, Fahrenheit) on the screen.' input name: 'tVocUnut', type: 'enum', title: 'tVOC unit on the Screen', options: TvocUnitOpts.options, defaultValue: TvocUnitOpts.defaultValue, required: true, description: \ 'Changes the tVOC unit (mg/m³, ppb) on the screen.' } } if (deviceType in ["Fingerbot"]) { input name: 'fingerbotMode', type: 'enum', title: 'Fingerbot Mode', options: FingerbotModeOpts.options, defaultValue: FingerbotModeOpts.defaultValue, required: true, description: \ 'Push or Switch.' input name: 'direction', type: 'enum', title: 'Fingerbot Direction', options: FingerbotDirectionOpts.options, defaultValue: FingerbotDirectionOpts.defaultValue, required: true, description: \ 'Finger movement direction.' input name: 'pushTime', type: 'number', title: 'Push Time', description: 'The time that the finger will stay in down position in Push mode, seconds', required: true, range: "0..255", defaultValue: 0 input name: 'upPosition', type: 'number', title: 'Up Postition', description: 'Finger up position, (0..50), percent', required: true, range: "0..50", defaultValue: 0 input name: 'dnPosition', type: 'number', title: 'Down Postition', description: 'Finger down position (51..100), percent', required: true, range: "51..100", defaultValue: 100 } input name: 'advancedOptions', type: 'bool', title: 'Advanced Options', description: "May not work for all device types!", 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.' //if (healthCheckMethod != null && safeToInt(healthCheckMethod.value) != 0) { 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 Integer DIGITAL_TIMER = 1000 // command was sent by this driver @Field static final Integer REFRESH_TIMER = 5000 // refresh time in miliseconds @Field static final Integer DEBOUNCING_TIMER = 300 // ignore switch events @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 String UNKNOWN = "UNKNOWN" @Field static final Integer DEFAULT_MIN_REPORTING_TIME = 10 // send the report event no more often than 10 seconds by default @Field static final Integer PRESENCE_COUNT_THRESHOLD = 3 // missing 3 checks will set the device healthStatus to 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 TemperatureScaleOpts = [ // bit 7 defaultValue: 0, options : [0: 'Celsius', 1: 'Fahrenheit'] ] @Field static final Map TvocUnitOpts = [ // bit 0 defaultValue: 1, options : [0: 'mg/m³', 1: 'ppb'] ] @Field static final Map FingerbotModeOpts = [ defaultValue: 0, options : [0: 'push', 1: 'switch'] ] @Field static final Map FingerbotDirectionOpts = [ defaultValue: 0, options : [0: 'normal', 1: 'reverse'] ] def isChattyDeviceReport(description) {return false /*(description?.contains("cluster: FC7E")) */} def isVINDSTIRKA() { (device?.getDataValue('model') ?: 'n/a') in ['VINDSTYRKA'] } def isAqaraTVOC() { (device?.getDataValue('model') ?: 'n/a') in ['lumi.airmonitor.acn01'] } def isAqaraTRV() { (device?.getDataValue('model') ?: 'n/a') in ['lumi.airrtc.agl001'] } def isAqaraFP1() { (device?.getDataValue('model') ?: 'n/a') in ['lumi.motion.ac01'] } def isFingerbot() { (device?.getDataValue('manufacturer') ?: 'n/a') in ['_TZ3210_dse8ogfy'] } /** * Parse Zigbee message * @param description Zigbee message in hex format */ void parse(final String description) { if (!isChattyDeviceReport(description)) { logDebug "parse: ${description}" } if (state.stats != null) state.stats['rxCtr'] = (state.stats['rxCtr'] ?: 0) + 1 else state.stats=[:] unschedule('deviceCommandTimeout') setHealthStatusOnline() final Map descMap = zigbee.parseDescriptionAsMap(description) if (descMap.profileId == '0000') { parseZdoClusters(descMap) return } if (descMap.isClusterSpecific == false) { parseGeneralCommandResponse(descMap) return } if (!isChattyDeviceReport(description)) {logDebug "descMap = ${descMap}"} // final String clusterName = clusterLookup(descMap.clusterInt) final String attribute = descMap.attrId ? " attribute 0x${descMap.attrId} (value ${descMap.value})" : '' //if (settings.logEnable) { log.trace "zigbee received ${clusterName} message" + attribute } switch (descMap.clusterInt as Integer) { case zigbee.BASIC_CLUSTER: //0x0000 parseBasicCluster(descMap) descMap.remove('additionalAttrs')?.each { final Map map -> parseBasicCluster(descMap + map) } break case zigbee.POWER_CONFIGURATION_CLUSTER: //0x0001 parsePowerCluster(descMap) descMap.remove('additionalAttrs')?.each { final Map map -> parsePowerCluster(descMap + map) } break case zigbee.ON_OFF_CLUSTER: parseOnOffCluster(descMap) descMap.remove('additionalAttrs')?.each { final Map map -> parseOnOffCluster(descMap + map) } break case 0x000C : // Aqara TVOC Air Monitor parseAirQualityIndexCluster(descMap) break case 0x0201 : // Aqara E1 TRV parseThermostatCluster(descMap) descMap.remove('additionalAttrs')?.each { final Map map -> parseThermostatCluster(descMap + map) } break case zigbee.ILLUMINANCE_MEASUREMENT_CLUSTER : //0x0400 log.warn "${clusterName} (${(descMap.clusterInt as Integer)}) parser not implemented yet!" break case zigbee.TEMPERATURE_MEASUREMENT_CLUSTER : //0x0402 parseTemperatureCluster(descMap) break case zigbee.RELATIVE_HUMIDITY_MEASUREMENT_CLUSTER : //0x0405 parseHumidityCluster(descMap) break case 0x042A : // pm2.5 parsePm25Cluster(descMap) break case zigbee.ELECTRICAL_MEASUREMENT_CLUSTER: parseElectricalMeasureCluster(descMap) descMap.remove('additionalAttrs')?.each { final Map map -> parseElectricalMeasureCluster(descMap + map) } break case zigbee.METERING_CLUSTER: parseMeteringCluster(descMap) descMap.remove('additionalAttrs')?.each { final Map map -> parseMeteringCluster(descMap + map) } break case 0xEF00 : // Tuya famous cluster parseTuyaCluster(descMap) descMap.remove('additionalAttrs')?.each { final Map map -> parseTuyaCluster(descMap + map) } break case 0xfc7e : // tVOC 'Sensirion VOC index' https://sensirion.com/media/documents/02232963/6294E043/Info_Note_VOC_Index.pdf parseAirQualityIndexCluster(descMap) break case XIAOMI_CLUSTER_ID : // 0xFCC0 Xiaomi cluster parseXiaomiCluster(descMap) descMap.remove('additionalAttrs')?.each { final Map m -> parseXiaomiCluster(descMap + m) } break default: if (settings.logEnable) { logWarn "zigbee received unknown message 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) { logWarn "zigbee received device object ${clusterName} error: ${statusName}" } else { logDebug "zigbee received device object ${clusterName} success: ${descMap.data}" } } /** * 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 parseConfigureResponse(descMap) break case 0x09: // read reporting configuration response parseReadReportingConfigResponse(descMap) 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 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 (statusCode > 0x00) { logWarn "zigbee read ${clusterLookup(descMap.clusterInt)} attribute 0x${attribute} error: ${status}" } else { logDebug "zigbee read ${clusterLookup(descMap.clusterInt)} attribute 0x${attribute} response: ${status} ${data}" } } /** * 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 (statusCode > 0x00) { logWarn "zigbee response write ${clusterLookup(descMap.clusterInt)} attribute error: ${statusName}" } else { logDebug "zigbee response write ${clusterLookup(descMap.clusterInt)} attribute response: ${statusName}" } } /** * Zigbee Configure Reporting Response Parsing * @param descMap Zigbee message in parsed map format */ void parseConfigureResponse(final Map descMap) { // TODO - parse the details of the configuration respose - cluster, min, max, delta ... 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 { logDebug "zigbee configure reporting response: ${statusName} ${descMap.data}" } } /** * 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) { logWarn "zigbee ${clusterLookup(descMap.clusterInt)} command 0x${commandId} error: ${status}" } else { logDebug "zigbee ${clusterLookup(descMap.clusterInt)} command 0x${commandId} response: ${status}" } } /** * 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 logDebug "Tuya check-in message (attribute ${descMap.attrId} reported: ${descMap.value})" def now = new Date().getTime() if (state.lastTx == null) state.lastTx = [:] def timeRunning = now.toInteger() - (state.lastTx["pingTime"] ?: '0').toInteger() if (timeRunning < MAX_PING_MILISECONDS) { sendRttEvent() } break case FIRMWARE_VERSION_ID: final String version = descMap.value ?: 'unknown' log.info "device firmware version is ${version}" updateDataValue('softwareBuild', version) break default: logWarn "zigbee received unknown Basic cluster attribute 0x${descMap.attrId} (value ${descMap.value})" break } } // Zigbee Attribute IDs @Field static final int AC_CURRENT_DIVISOR_ID = 0x0603 @Field static final int AC_CURRENT_MULTIPLIER_ID = 0x0602 @Field static final int AC_FREQUENCY_ID = 0x0300 @Field static final int AC_POWER_DIVISOR_ID = 0x0605 @Field static final int AC_POWER_MULTIPLIER_ID = 0x0604 @Field static final int AC_VOLTAGE_DIVISOR_ID = 0x0601 @Field static final int AC_VOLTAGE_MULTIPLIER_ID = 0x0600 @Field static final int ACTIVE_POWER_ID = 0x050B @Field static final int ATTRIBUTE_READING_INFO_SET = 0x0000 @Field static final int FIRMWARE_VERSION_ID = 0x4000 @Field static final int PING_ATTR_ID = 0x01 @Field static final int POWER_ON_OFF_ID = 0x0000 @Field static final int POWER_RESTORE_ID = 0x4003 @Field static final int RMS_CURRENT_ID = 0x0508 @Field static final int RMS_VOLTAGE_ID = 0x0505 @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' ] /* * ----------------------------------------------------------------------------- * Xiaomi cluster 0xFCC0 parser. * ----------------------------------------------------------------------------- */ @Field static final int XIAOMI_CLUSTER_ID = 0xFCC0 // Zigbee Attributes @Field static final int DIRECTION_MODE_ATTR_ID = 0x0144 @Field static final int MODEL_ATTR_ID = 0x05 //@Field static final int PING_ATTR_ID = 0x01 @Field static final int PRESENCE_ACTIONS_ATTR_ID = 0x0143 @Field static final int PRESENCE_ATTR_ID = 0x0142 @Field static final int REGION_EVENT_ATTR_ID = 0x0151 @Field static final int RESET_PRESENCE_ATTR_ID = 0x0157 @Field static final int SENSITIVITY_LEVEL_ATTR_ID = 0x010C @Field static final int SET_EDGE_REGION_ATTR_ID = 0x0156 @Field static final int SET_EXIT_REGION_ATTR_ID = 0x0153 @Field static final int SET_INTERFERENCE_ATTR_ID = 0x0154 @Field static final int SET_REGION_ATTR_ID = 0x0150 @Field static final int TRIGGER_DISTANCE_ATTR_ID = 0x0146 @Field static final int XIAOMI_RAW_ATTR_ID = 0xFFF2 @Field static final int XIAOMI_SPECIAL_REPORT_ID = 0x00F7 @Field static final Map MFG_CODE = [ mfgCode: 0x115F ] // Xiaomi Tags @Field static final int DIRECTION_MODE_TAG_ID = 0x67 @Field static final int SENSITIVITY_LEVEL_TAG_ID = 0x66 @Field static final int SWBUILD_TAG_ID = 0x08 @Field static final int TRIGGER_DISTANCE_TAG_ID = 0x69 @Field static final int PRESENCE_ACTIONS_TAG_ID = 0x66 @Field static final int PRESENCE_TAG_ID = 0x65 void parseXiaomiCluster(final Map descMap) { if (settings.logEnable) { log.trace "zigbee received xiaomi cluster attribute 0x${descMap.attrId} (value ${descMap.value})" } switch (descMap.attrInt as Integer) { case 0x00FC: // FP1 log.info 'unknown attribute - resetting?' break case PRESENCE_ATTR_ID: // FP1 final Integer value = hexStrToUnsignedInt(descMap.value) parseXiaomiClusterPresence(value) break case PRESENCE_ACTIONS_ATTR_ID: // FP1 final Integer value = hexStrToUnsignedInt(descMap.value) parseXiaomiClusterPresenceAction(value) break case REGION_EVENT_ATTR_ID: // FP1 // Region events can be sent fast and furious so buffer them final Integer regionId = HexUtils.hexStringToInt(descMap.value[0..1]) final Integer value = HexUtils.hexStringToInt(descMap.value[2..3]) if (settings.logEnable) { log.debug "xiaomi: region ${regionId} action is ${value}" } if (device.currentValue("region${regionId}") != null) { RegionUpdateBuffer.get(device.id).put(regionId, value) runInMillis(REGION_UPDATE_DELAY_MS, 'updateRegions') } break case SENSITIVITY_LEVEL_ATTR_ID: // FP1 final Integer value = hexStrToUnsignedInt(descMap.value) log.info "sensitivity level is '${SensitivityLevelOpts.options[value]}' (0x${descMap.value})" device.updateSetting('sensitivityLevel', [value: value.toString(), type: 'enum']) break case TRIGGER_DISTANCE_ATTR_ID: // FP1 final Integer value = hexStrToUnsignedInt(descMap.value) log.info "approach distance is '${ApproachDistanceOpts.options[value]}' (0x${descMap.value})" device.updateSetting('approachDistance', [value: value.toString(), type: 'enum']) break case DIRECTION_MODE_ATTR_ID: // FP1 final Integer value = hexStrToUnsignedInt(descMap.value) log.info "monitoring direction mode is '${DirectionModeOpts.options[value]}' (0x${descMap.value})" device.updateSetting('directionMode', [value: value.toString(), type: 'enum']) break case XIAOMI_SPECIAL_REPORT_ID: // sent every 5 minutes final Map tags = decodeXiaomiTags(descMap.value) parseXiaomiClusterTags(tags) break case XIAOMI_RAW_ATTR_ID: // FP1 final byte[] rawData = HexUtils.hexStringToByteArray(descMap.value) if (rawData.size() == 24 && settings.enableDistanceDirection) { final int degrees = rawData[19] final int distanceCm = (rawData[17] << 8) | (rawData[18] & 0x00ff) if (settings.logEnable) { log.debug "location ${degrees}°, ${distanceCm}cm" } runIn(1, 'updateLocation', [ data: [ degrees: degrees, distanceCm: distanceCm ] ]) } break case 0x0271: // result['system_mode'] = {1: 'heat', 0: 'off'}[value]; final Integer value = hexStrToUnsignedInt(descMap.value) logInfo "system_mode raw = ${value}" break; case 0x0272: // result['preset'] = {2: 'away', 1: 'auto', 0: 'manual'}[value]; final Integer value = hexStrToUnsignedInt(descMap.value) logInfo "preset raw = ${value}" break; case 0x0273: // result['window_detection'] = {1: 'ON', 0: 'OFF'}[value]; final Integer value = hexStrToUnsignedInt(descMap.value) logInfo "window_detection raw = ${value}" break; case 0x0274: // result['valve_detection'] = {1: 'ON', 0: 'OFF'}[value]; final Integer value = hexStrToUnsignedInt(descMap.value) logInfo "valve_detection raw = ${value}" break; case 0x0275: // result['valve_alarm'] = {1: true, 0: false}[value]; final Integer value = hexStrToUnsignedInt(descMap.value) logInfo "valve_alarm raw = ${value}" break; case 0x0277: // result['child_lock'] = {1: 'LOCK', 0: 'UNLOCK'}[value]; final Integer value = hexStrToUnsignedInt(descMap.value) logInfo "child_lock raw = ${value}" break; case 0x0279: // result['away_preset_temperature'] = (value / 100).toFixed(1); final Integer value = hexStrToUnsignedInt(descMap.value) logInfo "away_preset_temperature raw = ${value}" break; case 0x027a: // result['window_open'] = {1: true, 0: false}[value]; final Integer value = hexStrToUnsignedInt(descMap.value) logInfo "window_open raw = ${value}" break; case 0x027b: // result['calibrated'] = {1: true, 0: false}[value]; final Integer value = hexStrToUnsignedInt(descMap.value) logInfo "calibrated raw = ${value}" break; case 0x0276: // unknown case 0x027c: // unknown case 0x027d: // unknown case 0x0280: // unknown case 0xfff2: // unknown case 0x00ff: // unknown case 0x00f7: // unknown case 0xfff2: // unknown try { final Integer value = hexStrToUnsignedInt(descMap.value) logDebug "Aqara E1 TRV unknown attribute ${descMap.attrInt} value raw = ${value}" } catch (e) { logWarn "exception caught while processing Aqara E1 TRV unknown attribute ${descMap.attrInt} descMap.value = ${descMap.value}" } break; case 0x027e: // result['sensor'] = {1: 'external', 0: 'internal'}[value]; final Integer value = hexStrToUnsignedInt(descMap.value) logInfo "sensor raw = ${value}" break; case 0x040a: // E1 battery final Integer value = hexStrToUnsignedInt(descMap.value) logInfo "battery raw = ${value}" break case 0x00FF: // unknown break default: log.warn "zigbee received unknown xiaomi cluster 0xFCC0 attribute 0x${descMap.attrId} (value ${descMap.value})" break } } void parseXiaomiClusterTags(final Map tags) { tags.each { final Integer tag, final Object value -> switch (tag) { case 0x01: // battery voltage logDebug "xiaomi decode tag: 0x${intToHexStr(tag, 1)} battery voltage is ${value/1000}V (raw=${value})" break case 0x03: logDebug "xiaomi decode tag: 0x${intToHexStr(tag, 1)} device temperature is ${value}°" break case 0x05: logDebug "xiaomi decode tag: 0x${intToHexStr(tag, 1)} RSSI is ${value}" break case 0x06: logDebug "xiaomi decode tag: 0x${intToHexStr(tag, 1)} LQI is ${value}" break case 0x08: // SWBUILD_TAG_ID: final String swBuild = '0.0.0_' + (value & 0xFF).toString().padLeft(4, '0') logDebug "xiaomi decode tag: 0x${intToHexStr(tag, 1)} swBuild is ${swBuild} (raw ${value})" device.updateDataValue(/*'softwareBuild'*/aqaraVersion, swBuild) break case 0x0a: logDebug "xiaomi decode tag: 0x${intToHexStr(tag, 1)} Parent NWK is ${value}" break case 0x0b: logDebug "xiaomi decode tag: 0x${intToHexStr(tag, 1)} light level is ${value}" break case 0x64: logDebug "xiaomi decode tag: 0x${intToHexStr(tag, 1)} temperature is ${value/100} (raw ${value})" // Aqara TVOC // TODO - also smoke gas/density if UINT ! break case 0x65: if (isAqaraFP1()) { logDebug "xiaomi decode PRESENCE_TAG_ID tag: 0x${intToHexStr(tag, 1)}=${value}" } else { logDebug "xiaomi decode tag: 0x${intToHexStr(tag, 1)} humidity is ${value/100} (raw ${value})" } // Aqara TVOC break case 0x66: if (isAqaraFP1()) { logDebug "xiaomi decode SENSITIVITY_LEVEL_TAG_ID tag: 0x${intToHexStr(tag, 1)}=${value}" } else if (isAqaraTVOC()) { logDebug "xiaomi decode tag: 0x${intToHexStr(tag, 1)} airQualityIndex is ${value}" } // Aqara TVOC level (in ppb) else { logDebug "xiaomi decode tag: 0x${intToHexStr(tag, 1)} presure is ${value}" } break case 0x67: if (isAqaraFP1()) { logDebug "xiaomi decode DIRECTION_MODE_TAG_ID tag: 0x${intToHexStr(tag, 1)}=${value}" } else { logDebug "xiaomi decode unknown tag: 0x${intToHexStr(tag, 1)}=${value}" } // Aqara TVOC: // air quality (as 6 - #stars) ['excellent', 'good', 'moderate', 'poor', 'unhealthy'][val - 1] break case 0x69: if (isAqaraFP1()) { logDebug "xiaomi decode TRIGGER_DISTANCE_TAG_ID tag: 0x${intToHexStr(tag, 1)}=${value}" } else { logDebug "xiaomi decode unknown tag: 0x${intToHexStr(tag, 1)}=${value}" } break case 0x6a: if (isAqaraFP1()) { logDebug "xiaomi decode FP1 unknown tag: 0x${intToHexStr(tag, 1)}=${value}" } else { logDebug "xiaomi decode MOTION SENSITIVITY tag: 0x${intToHexStr(tag, 1)}=${value}" } break case 0x6b: if (isAqaraFP1()) { logDebug "xiaomi decode FP1 unknown tag: 0x${intToHexStr(tag, 1)}=${value}" } else { logDebug "xiaomi decode MOTION LED tag: 0x${intToHexStr(tag, 1)}=${value}" } break case 0x95: logDebug "xiaomi decode tag: 0x${intToHexStr(tag, 1)} energy is ${value}" break case 0x96: logDebug "xiaomi decode tag: 0x${intToHexStr(tag, 1)} voltage is ${value}" break case 0x97: logDebug "xiaomi decode tag: 0x${intToHexStr(tag, 1)} current is ${value}" break case 0x98: logDebug "xiaomi decode tag: 0x${intToHexStr(tag, 1)} power is ${value}" break case 0x9b: logDebug "xiaomi decode CONSUMER CONNECTED tag: 0x${intToHexStr(tag, 1)}=${value}" break default: logDebug "xiaomi decode unknown tag: 0x${intToHexStr(tag, 1)}=${value}" } } } /** * Reads a specified number of little-endian bytes from a given * ByteArrayInputStream and returns a BigInteger. */ private static BigInteger readBigIntegerBytes(final ByteArrayInputStream stream, final int length) { final byte[] byteArr = new byte[length] stream.read(byteArr, 0, length) BigInteger bigInt = BigInteger.ZERO for (int i = byteArr.length - 1; i >= 0; i--) { bigInt |= (BigInteger.valueOf((byteArr[i] & 0xFF) << (8 * i))) } return bigInt } /** * Decodes a Xiaomi Zigbee cluster attribute payload in hexadecimal format and * returns a map of decoded tag number and value pairs where the value is either a * BigInteger for fixed values or a String for variable length. */ private static Map decodeXiaomiTags(final String hexString) { final Map results = [:] final byte[] bytes = HexUtils.hexStringToByteArray(hexString) new ByteArrayInputStream(bytes).withCloseable { final stream -> while (stream.available() > 2) { int tag = stream.read() int dataType = stream.read() Object value if (DataType.isDiscrete(dataType)) { int length = stream.read() byte[] byteArr = new byte[length] stream.read(byteArr, 0, length) value = new String(byteArr) } else { int length = DataType.getLength(dataType) value = readBigIntegerBytes(stream, length) } results[tag] = value } } return results } /* * ----------------------------------------------------------------------------- * Standard clusters reporting handlers * ----------------------------------------------------------------------------- */ void parsePowerCluster(final Map descMap) { if (state.lastRx == null) { state.lastRx = [:] } if (descMap.value == null || descMap.value == 'FFFF') { return } // invalid or unknown value final long rawValue = hexStrToUnsignedInt(descMap.value) if (descMap.attrId == "0020") { sendBatteryVoltageEvent(rawValue) sendBatteryVoltageEvent(rawValue, convertToPercent=true) } else if (descMap.attrId == "0021") { sendBatteryPercentageEvent(rawValue * 2) } else { } } def sendBatteryVoltageEvent(rawValue, Boolean convertToPercent=false) { logDebug "batteryVoltage = ${(double)rawValue / 10.0} V" def result = [:] def volts = rawValue / 10 if (!(rawValue == 0 || rawValue == 255)) { def minVolts = 2.2 def maxVolts = 3.2 def pct = (volts - minVolts) / (maxVolts - minVolts) def roundedPct = Math.round(pct * 100) if (roundedPct <= 0) roundedPct = 1 if (roundedPct >100) roundedPct = 100 if (convertToPercent == true) { result.value = Math.min(100, roundedPct) result.name = 'battery' result.unit = '%' result.descriptionText = "battery is ${roundedPct} %" } else { result.value = volts result.name = 'batteryVoltage' result.unit = 'V' result.descriptionText = "battery is ${volts} Volts" } result.type = 'physical' result.isStateChange = true logInfo "${result.descriptionText}" sendEvent(result) } else { logWarn "ignoring BatteryResult(${rawValue})" } } def sendBatteryPercentageEvent( batteryPercent, isDigital=false ) { def map = [:] map.name = 'battery' map.timeStamp = now() map.value = batteryPercent < 0 ? 0 : batteryPercent > 100 ? 100 : (batteryPercent as int) map.unit = '%' map.type = isDigital ? 'digital' : 'physical' map.descriptionText = "${map.name} is ${map.value} ${map.unit}" map.isStateChange = true // def latestBatteryEvent = device.latestState('battery', skipCache=true) def latestBatteryEventTime = latestBatteryEvent != null ? latestBatteryEvent.getDate().getTime() : now() //log.debug "battery latest state timeStamp is ${latestBatteryTime} now is ${now()}" def timeDiff = ((now() - latestBatteryEventTime) / 1000) as int if (settings?.batteryDelay == null || (settings?.batteryDelay as int) == 0 || timeDiff > (settings?.batteryDelay as int)) { // send it now! sendDelayedBatteryPercentageEvent(map) } else { def delayedTime = (settings?.batteryDelay as int) - timeDiff map.delayed = delayedTime map.descriptionText += " [delayed ${map.delayed} seconds]" logDebug "this battery event (${map.value}%) will be delayed ${delayedTime} seconds" runIn( delayedTime, 'sendDelayedBatteryEvent', [overwrite: true, data: map]) } } private void sendDelayedBatteryPercentageEvent(Map map) { logInfo "${map.descriptionText}" //map.each {log.trace "$it"} sendEvent(map) } private void sendDelayedBatteryVoltageEvent(Map map) { logInfo "${map.descriptionText}" //map.each {log.trace "$it"} sendEvent(map) } /* * ----------------------------------------------------------------------------- * on/off cluster * ----------------------------------------------------------------------------- */ void parseOnOffCluster(final Map descMap) { if (state.lastRx == null) { state.lastRx = [:] } if (descMap.value == null || descMap.value == 'FFFF') { return } // invalid or unknown value final long rawValue = hexStrToUnsignedInt(descMap.value) if (descMap.attrId == "0000") { sendSwitchEvent(rawValue) } else { logWarn "unprocessed OnOffCluster attribute ${descMap.attrId}" } } def sendSwitchEvent( switchValue ) { def value = (switchValue == null) ? 'unknown' : (switchValue == 0x00) ? 'off' : (switchValue == 0x01) ? 'on' : 'unknown' def map = [:] boolean bWasChange = false boolean debounce = state.states["debounce"] ?: false def lastSwitch = state.states["lastSwitch"] ?: "unknown" if (debounce == true && value == lastSwitch) { // some devices send only catchall events, some only readattr reports, but some will fire both... logDebug "Ignored duplicated switch event ${value}" runInMillis( DEBOUNCING_TIMER, switchDebouncingClear, [overwrite: true]) return null } else { //log.trace "value=${value} lastSwitch=${state.states['lastSwitch']}" } def isDigital = state.states["isDigital"] map.type = isDigital == true ? "digital" : "physical" if (lastSwitch != value ) { bWasChange = true logDebug "switch state changed from ${lastSwitch} to ${value}" state.states["debounce"] = true state.states["lastSwitch"] = value runInMillis( DEBOUNCING_TIMER, switchDebouncingClear, [overwrite: true]) } else { state.states["debounce"] = true runInMillis( DEBOUNCING_TIMER, switchDebouncingClear, [overwrite: true]) } map.name = "switch" map.value = value boolean isRefresh = state.states["isRefresh"] ?: false if (isRefresh == true) { map.descriptionText = "${device.displayName} is ${value} (Refresh)" } else { map.descriptionText = "${device.displayName} is ${value} [${map.type}]" } logInfo "${map.descriptionText}" sendEvent(map) clearIsDigital() } def clearIsDigital() { state.states["isDigital"] = false } def switchDebouncingClear() { state.states["debounce"] = false } def isRefreshRequestClear() { state.states["isRefresh"] = false } def off() { if ((settings?.alwaysOn ?: false) == true) { logWarn "AlwaysOn option for ${device.displayName} is enabled , the command to switch it OFF is ignored!" return } state.states["isDigital"] = true logDebug "Switching ${device.displayName} Off" def cmds = zigbee.off() /* if (device.getDataValue("model") == "HY0105") { cmds += zigbee.command(0x0006, 0x00, "", [destEndpoint: 0x02]) } else if (state.model == "TS0601") { if (isDinRail() || isRTXCircuitBreaker()) { cmds = sendTuyaCommand("10", DP_TYPE_BOOL, "00") } else { cmds = zigbee.command(0xEF00, 0x0, "00010101000100") } } else if (isHEProblematic()) { cmds = ["he cmd 0x${device.deviceNetworkId} 0x01 0x0006 0 {}","delay 200"] logWarn "isHEProblematic() : sending off() : ${cmds}" } else if (device.endpointId == "F2") { cmds = ["he cmd 0x${device.deviceNetworkId} 0x01 0x0006 0 {}","delay 200"] } */ runInMillis( DIGITAL_TIMER, clearIsDigital, [overwrite: true]) sendZigbeeCommands(cmds) } def on() { state.isDigital = true logDebug "Switching ${device.displayName} On" def cmds = zigbee.on() /* if (device.getDataValue("model") == "HY0105") { cmds += zigbee.command(0x0006, 0x01, "", [destEndpoint: 0x02]) } else if (state.model == "TS0601") { if (isDinRail() || isRTXCircuitBreaker()) { cmds = sendTuyaCommand("10", DP_TYPE_BOOL, "01") } else { cmds = zigbee.command(0xEF00, 0x0, "00010101000101") } } else if (isHEProblematic()) { cmds = ["he cmd 0x${device.deviceNetworkId} 0x01 0x0006 1 {}","delay 200"] logWarn "isHEProblematic() : sending off() : ${cmds}" } else if (device.endpointId == "F2") { cmds = ["he cmd 0x${device.deviceNetworkId} 0x01 0x0006 1 {}","delay 200"] } */ runInMillis( DIGITAL_TIMER, clearIsDigital, [overwrite: true]) sendZigbeeCommands(cmds) } /* * ----------------------------------------------------------------------------- * temperature * ----------------------------------------------------------------------------- */ void parseTemperatureCluster(final Map descMap) { if (state.lastRx == null) { state.lastRx = [:] } if (descMap.value == null || descMap.value == 'FFFF') { return } // invalid or unknown value final long value = hexStrToUnsignedInt(descMap.value) handleTemperatureEvent(value/100.0F as Float) } void handleTemperatureEvent( Float temperature, Boolean isDigital=false ) { def eventMap = [:] if (state.stats != null) state.stats['tempCtr'] = (state.stats['tempCtr'] ?: 0) + 1 else state.stats=[:] eventMap.name = "temperature" def Scale = location.temperatureScale if (Scale == "F") { temperature = (temperature * 1.8) + 32 eventMap.unit = "\u00B0"+"F" } else { eventMap.unit = "\u00B0"+"C" } def tempCorrected = temperature + safeToDouble(settings?.temperatureOffset ?: 0) eventMap.value = Math.round(tempCorrected * 10) / 10.0 eventMap.type = isDigital == true ? "digital" : "physical" //eventMap.isStateChange = true eventMap.descriptionText = "${eventMap.name} is ${eventMap.value} ${eventMap.unit}" Integer timeElapsed = Math.round((now() - (state.lastRx['tempTime'] ?: now()))/1000) Integer minTime = settings?.minReportingTimeTemp ?: DEFAULT_MIN_REPORTING_TIME Integer timeRamaining = (minTime - timeElapsed) as Integer if (timeElapsed >= minTime) { logInfo "${eventMap.descriptionText}" unschedule("sendDelayedTempEvent") //get rid of stale queued reports state.lastRx['tempTime'] = now() sendEvent(eventMap) } else { // queue the event eventMap.type = "delayed" logDebug "${device.displayName} DELAYING ${timeRamaining} seconds event : ${eventMap}" runIn(timeRamaining, 'sendDelayedTempEvent', [overwrite: true, data: eventMap]) } } private void sendDelayedTempEvent(Map eventMap) { logInfo "${eventMap.descriptionText} (${eventMap.type})" state.lastRx['tempTime'] = now() // TODO - -(minReportingTimeHumidity * 2000) sendEvent(eventMap) } /* * ----------------------------------------------------------------------------- * humidity * ----------------------------------------------------------------------------- */ void parseHumidityCluster(final Map descMap) { if (state.lastRx == null) { state.lastRx = [:] } if (descMap.value == null || descMap.value == 'FFFF') { return } // invalid or unknown value final long value = hexStrToUnsignedInt(descMap.value) handleHumidityEvent(value/100.0F as Float) } void handleHumidityEvent( Float humidity, Boolean isDigital=false ) { def eventMap = [:] if (state.stats != null) state.stats['humiCtr'] = (state.stats['humiCtr'] ?: 0) + 1 else state.stats=[:] double humidityAsDouble = safeToDouble(humidity) + safeToDouble(settings?.humidityOffset ?: 0) if (humidityAsDouble <= 0.0 || humidityAsDouble > 100.0) { logWarn "ignored invalid humidity ${humidity} (${humidityAsDouble})" return } eventMap.value = Math.round(humidityAsDouble) eventMap.name = "humidity" eventMap.unit = "% RH" eventMap.type = isDigital == true ? "digital" : "physical" //eventMap.isStateChange = true eventMap.descriptionText = "${eventMap.name} is ${humidityAsDouble.round(1)} ${eventMap.unit}" Integer timeElapsed = Math.round((now() - (state.lastRx['humiTime'] ?: now()))/1000) Integer minTime = settings?.minReportingTimeHumidity ?: DEFAULT_MIN_REPORTING_TIME Integer timeRamaining = (minTime - timeElapsed) as Integer if (timeElapsed >= minTime) { logInfo "${eventMap.descriptionText}" unschedule("sendDelayedHumidityEvent") state.lastRx['humiTime'] = now() sendEvent(eventMap) } else { eventMap.type = "delayed" logDebug "DELAYING ${timeRamaining} seconds event : ${eventMap}" runIn(timeRamaining, 'sendDelayedHumidityEvent', [overwrite: true, data: eventMap]) } } private void sendDelayedHumidityEvent(Map eventMap) { logInfo "${eventMap.descriptionText} (${eventMap.type})" state.lastRx['humiTime'] = now() // TODO - -(minReportingTimeHumidity * 2000) sendEvent(eventMap) } /* * ----------------------------------------------------------------------------- * pm2.5 * ----------------------------------------------------------------------------- */ void parsePm25Cluster(final Map descMap) { if (state.lastRx == null) { state.lastRx = [:] } if (descMap.value == null || descMap.value == 'FFFF') { return } // invalid or unknown value def value = hexStrToUnsignedInt(descMap.value) Float floatValue = Float.intBitsToFloat(value.intValue()) logDebug "pm25 float valye = ${floatValue}" handlePm25Event(floatValue as Integer) } void handlePm25Event( Integer pm25, Boolean isDigital=false ) { def eventMap = [:] if (state.stats != null) state.stats['pm25Ctr'] = (state.stats['pm25Ctr'] ?: 0) + 1 else state.stats=[:] double pm25AsDouble = safeToDouble(pm25) + safeToDouble(settings?.pm25Offset ?: 0) if (pm25AsDouble <= 0.0 || pm25AsDouble > 999.0) { logWarn "ignored invalid pm25 ${pm25} (${pm25AsDouble})" return } eventMap.value = Math.round(pm25AsDouble) eventMap.name = "pm25" eventMap.unit = "\u03BCg/m3" //"mg/m3" eventMap.type = isDigital == true ? "digital" : "physical" eventMap.isStateChange = true eventMap.descriptionText = "${eventMap.name} is ${pm25AsDouble.round()} ${eventMap.unit}" Integer timeElapsed = Math.round((now() - (state.lastRx['pm25Time'] ?: now()))/1000) Integer minTime = settings?.minReportingTimePm25 ?: DEFAULT_MIN_REPORTING_TIME Integer timeRamaining = (minTime - timeElapsed) as Integer if (timeElapsed >= minTime) { logInfo "${eventMap.descriptionText}" unschedule("sendDelayedPm25Event") state.lastRx['pm25Time'] = now() sendEvent(eventMap) } else { eventMap.type = "delayed" logDebug "DELAYING ${timeRamaining} seconds event : ${eventMap}" runIn(timeRamaining, 'sendDelayedPm25Event', [overwrite: true, data: eventMap]) } } private void sendDelayedPm25Event(Map eventMap) { logInfo "${eventMap.descriptionText} (${eventMap.type})" state.lastRx['pm25Time'] = now() // TODO - -(minReportingTimeHumidity * 2000) sendEvent(eventMap) } /* * ----------------------------------------------------------------------------- * airQualityIndex * ----------------------------------------------------------------------------- */ @Field static final Map AirQualityIndexCheckIntervalOpts = [ // used by airQualityIndexCheckInterval defaultValue: 60, options : [0: 'Disabled', 10: 'Every 10 seconds', 30: 'Every 30 seconds', 60: 'Every 1 minute', 300: 'Every 5 minutes', 900: 'Every 15 minutes', 3600: 'Every 1 hour'] ] void parseAirQualityIndexCluster(final Map descMap) { if (state.lastRx == null) { state.lastRx = [:] } if (descMap.value == null || descMap.value == 'FFFF') { return } // invalid or unknown value def value = hexStrToUnsignedInt(descMap.value) Float floatValue = Float.intBitsToFloat(value.intValue()) handleAirQualityIndexEvent(floatValue as Integer) } void handleAirQualityIndexEvent( Integer tVoc, Boolean isDigital=false ) { def eventMap = [:] if (state.stats != null) state.stats['tVocCtr'] = (state.stats['tVocCtr'] ?: 0) + 1 else state.stats=[:] Integer tVocCorrected= safeToDouble(tVoc) + safeToDouble(settings?.tVocOffset ?: 0) if (tVocCorrected < 0 || tVocCorrected > 999) { logWarn "ignored invalid tVoc ${tVoc} (${tVocCorrected})" return } if (safeToInt((device.currentState("airQualityIndex")?.value ?: -1)) == tVocCorrected) { logDebug "ignored duplicated tVoc ${tVoc} (${tVocCorrected})" return } eventMap.value = tVocCorrected as Integer eventMap.name = "airQualityIndex" eventMap.unit = "" eventMap.type = isDigital == true ? "digital" : "physical" eventMap.descriptionText = "${eventMap.name} is ${tVocCorrected} ${eventMap.unit}" Integer timeElapsed = ((now() - (state.lastRx['tVocTime'] ?: now() -10000 ))/1000) as Integer Integer minTime = settings?.minReportingTimetVoc ?: DEFAULT_MIN_REPORTING_TIME Integer timeRamaining = (minTime - timeElapsed) as Integer if (timeElapsed >= minTime) { logInfo "${eventMap.descriptionText}" unschedule("sendDelayedtVocEvent") state.lastRx['tVocTime'] = now() sendEvent(eventMap) sendAirQualityLevelEvent(airQualityIndexToLevel(safeToInt(eventMap.value))) } else { eventMap.type = "delayed" //logDebug "DELAYING ${timeRamaining} seconds event : ${eventMap}" runIn(timeRamaining, 'sendDelayedtVocEvent', [overwrite: true, data: eventMap]) } } private void sendDelayedtVocEvent(Map eventMap) { logInfo "${eventMap.descriptionText} (${eventMap.type})" state.lastRx['tVocTime'] = now() // TODO - -(minReportingTimeHumidity * 2000) sendEvent(eventMap) sendAirQualityLevelEvent(airQualityIndexToLevel(safeToInt(eventMap.value))) } // https://github.com/zigpy/zigpy/discussions/691 String airQualityIndexToLevel(final Integer index) { String level if (index <0 ) { level = 'unknown' } else if (index < 50) { level = 'Good' } else if (index < 100) { level = 'Moderate' } else if (index < 150) { level = 'Unhealthy for Sensitive Groups' } else if (index < 200) { level = 'Unhealthy' } else if (index < 300) { level = 'Very Unhealthy' } else if (index < 501) { level = 'Hazardous' } else { level = 'Hazardous Out of Range' } return level } private void sendAirQualityLevelEvent(String level) { if (level == null || level == '') { return } def descriptionText = "Air Quality Level is ${level}" logInfo "${descriptionText}" sendEvent(name: "airQualityLevel", value: level, descriptionText: descriptionText, unit: "", isDigital: true) } /** * Schedule a Air Quality Index check * @param intervalMins interval in seconds */ private void scheduleAirQualityIndexCheck(final int intervalSecs) { String cron = getCron( intervalSecs ) schedule(cron, 'autoPoll') } private void unScheduleAirQualityIndexCheck() { unschedule('autoPoll') } /* * ----------------------------------------------------------------------------- * thermostat cluster 0x0201 * ----------------------------------------------------------------------------- */ void parseThermostatCluster(final Map descMap) { final Integer value = safeToInt(hexStrToUnsignedInt(descMap.value)) if (settings.logEnable) { log.trace "zigbee received Thermostat cluster (0x0201) attribute 0x${descMap.attrId} value ${value} (raw ${descMap.value})" } switch (descMap.attrInt as Integer) { case 0x000: // temperature logInfo "temperature = ${value/100.0} (raw ${value})" handleTemperatureEvent(value/100.0) break case 0x0011: // cooling setpoint logInfo "cooling setpoint = ${value/100.0} (raw ${value})" break case 0x0012: // heating setpoint logInfo "heating setpoint = ${value/100.0} (raw ${value})" handleHeatingSetpointEvent(value/100.0) break case 0x001c: // mode logInfo "mode = ${value} (raw ${value})" break case 0x001e: // thermostatRunMode logInfo "thermostatRunMode = ${value} (raw ${value})" break case 0x0020: // battery logInfo "battery = ${value} (raw ${value})" break case 0x0023: // thermostatHoldMode logInfo "thermostatHoldMode = ${value} (raw ${value})" break case 0x0029: // thermostatOperatingState logInfo "thermostatOperatingState = ${value} (raw ${value})" break case 0xfff2: // unknown logDebug "Aqara E1 TRV unknown attribute ${descMap.attrInt} value raw = ${value}" break; default: log.warn "zigbee received unknown Thermostat cluster (0x0201) attribute 0x${descMap.attrId} (value ${descMap.value})" break } } def handleHeatingSetpointEvent( temperature ) { setHeatingSetpoint(temperature) } // ThermostatHeatingSetpoint command // sends TuyaCommand and checks after 4 seconds // 1°C steps. (0.5°C setting on the TRV itself, rounded for zigbee interface) def setHeatingSetpoint( temperature ) { def previousSetpoint = device.currentState('heatingSetpoint')?.value ?: 0 double tempDouble logDebug "setHeatingSetpoint temperature = ${temperature} as int = ${temperature as int} (previousSetpointt = ${previousSetpoint})" if (true) { logDebug "0.5 C correction of the heating setpoint${temperature}" tempDouble = safeToDouble(temperature) tempDouble = Math.round(tempDouble * 2) / 2.0 } else { if (temperature != (temperature as int)) { if ((temperature as double) > (previousSetpoint as double)) { temperature = (temperature + 0.5 ) as int } else { temperature = temperature as int } logDebug "corrected heating setpoint ${temperature}" } tempDouble = temperature } def maxTemp = settings?.maxThermostatTemp ?: 50 def minTemp = settings?.minThermostatTemp ?: 5 if (tempDouble > maxTemp ) tempDouble = maxTemp if (tempDouble < minTemp) tempDouble = minTemp tempDouble = tempDouble.round(1) Map eventMap = [name: "heatingSetpoint", value: tempDouble, unit: "\u00B0"+"C"] eventMap.descriptionText = "heatingSetpoint is ${tempDouble}" sendHeatingSetpointEvent(eventMap) eventMap = [name: "thermostatSetpoint", value: tempDouble, unit: "\u00B0"+"C"] eventMap.descriptionText = null sendHeatingSetpointEvent(eventMap) updateDataValue("lastRunningMode", "heat") // zigbee.writeAttribute(0x0201, 0x12, 0x29, (tempDouble * 100) as int) } private void sendHeatingSetpointEvent(Map eventMap) { if (eventMap.descriptionText != null) { logInfo "${eventMap.descriptionText}" } sendEvent(eventMap) } // ------------------------------------------------------------------------------------------------------------------------- /* * ----------------------------------------------------------------------------- * Tuya cluster EF00 specific code * ----------------------------------------------------------------------------- */ private static getCLUSTER_TUYA() { 0xEF00 } private static getSETDATA() { 0x00 } private static getSETTIME() { 0x24 } // Tuya Commands private static getTUYA_REQUEST() { 0x00 } private static getTUYA_REPORTING() { 0x01 } private static getTUYA_QUERY() { 0x02 } private static getTUYA_STATUS_SEARCH() { 0x06 } private static getTUYA_TIME_SYNCHRONISATION() { 0x24 } // tuya DP type private static getDP_TYPE_RAW() { "01" } // [ bytes ] private static getDP_TYPE_BOOL() { "01" } // [ 0/1 ] private static getDP_TYPE_VALUE() { "02" } // [ 4 byte value ] private static getDP_TYPE_STRING() { "03" } // [ N byte string ] private static getDP_TYPE_ENUM() { "04" } // [ 0-255 ] private static getDP_TYPE_BITMAP() { "05" } // [ 1,2,4 bytes ] as bits void parseTuyaCluster(final Map descMap) { if (descMap?.clusterInt == CLUSTER_TUYA && descMap?.command == "24") { //getSETTIME logDebug "Tuya time synchronization request from device, descMap = ${descMap}" def offset = 0 try { offset = location.getTimeZone().getOffset(new Date().getTime()) //if (settings?.logEnable) log.debug "${device.displayName} timezone offset of current location is ${offset}" } catch(e) { logWarn "cannot resolve current location. please set location in Hubitat location setting. Setting timezone offset to zero" } def cmds = zigbee.command(CLUSTER_TUYA, SETTIME, "0008" +zigbee.convertToHexString((int)(now()/1000),8) + zigbee.convertToHexString((int)((now()+offset)/1000), 8)) logDebug "sending time data : ${cmds}" cmds.each{ sendHubCommand(new hubitat.device.HubAction(it, hubitat.device.Protocol.ZIGBEE)) } //if (state.txCounter != null) state.txCounter = state.txCounter + 1 } else if (descMap?.clusterInt == CLUSTER_TUYA && descMap?.command == "0B") { // ZCL Command Default Response String clusterCmd = descMap?.data[0] def status = descMap?.data[1] logDebug "device has received Tuya cluster ZCL command 0x${clusterCmd} response 0x${status} data = ${descMap?.data}" if (status != "00") { logWarn "ATTENTION! manufacturer = ${device.getDataValue("manufacturer")} unsupported Tuya cluster ZCL command 0x${clusterCmd} response 0x${status} data = ${descMap?.data} !!!" } } else if ((descMap?.clusterInt == CLUSTER_TUYA) && (descMap?.command == "01" || descMap?.command == "02" || descMap?.command == "05" || descMap?.command == "06")) { def dataLen = descMap?.data.size() //log.warn "dataLen=${dataLen}" def transid = zigbee.convertHexToInt(descMap?.data[1]) // "transid" is just a "counter", a response will have the same transid as the command if (dataLen <= 5) { logWarn "unprocessed short Tuya command response: dp_id=${descMap?.data[3]} dp=${descMap?.data[2]} fncmd_len=${fncmd_len} data=${descMap?.data})" return } for (int i = 0; i < (dataLen-4); ) { def dp = zigbee.convertHexToInt(descMap?.data[2+i]) // "dp" field describes the action/message of a command frame def dp_id = zigbee.convertHexToInt(descMap?.data[3+i]) // "dp_identifier" is device dependant def fncmd_len = zigbee.convertHexToInt(descMap?.data[5+i]) def fncmd = getTuyaAttributeValue(descMap?.data, i) // //if (settings?.logEnable) log.trace "${device.displayName} dp_id=${dp_id} dp=${dp} fncmd=${fncmd} fncmd_len=${fncmd_len} (index=${i})" processTuyaDP( descMap, dp, dp_id, fncmd) i = i + fncmd_len + 4; } } else { logWarn "unprocessed Tuya command ${descMap?.command}" } } void processTuyaDP( descMap, dp, dp_id, fncmd) { switch (dp) { case 0x01 : // on/off sendSwitchEvent(fncmd) break /* Switch1 1 Mode 101 Degree of declining code: 102 Duration 103 Switch Reverse 104 Battery Power 105 Increase 106 Tact Switch 107 Click 108 Custom Program 109 Producion Test 110 Sports Statistics 111 Custom Timing 112 */ case 0x65 : // (101) if (isFingerbot()) { def value = FingerbotModeOpts.options[fncmd as int] def descriptionText = "Fingerbot mode is ${value} (${fncmd})" sendEvent(name: "fingerbotMode", value: value, descriptionText: descriptionText) logInfo "${descriptionText}" } else { logDebug "Tuya cmd: dp=${dp} value=${fncmd} descMap.data = ${descMap?.data}" } break case 0x66 : // (102) if (isFingerbot()) { def value = fncmd as int def descriptionText = "Fingerbot Down Position is ${value} %" sendEvent(name: "dnPosition", value: value, descriptionText: descriptionText) logInfo "${descriptionText}" } else { logDebug "Tuya cmd: dp=${dp} value=${fncmd} descMap.data = ${descMap?.data}" } break case 0x67 : // (103) if (isFingerbot()) { def value = fncmd as int def descriptionText = "Fingerbot push time (duration) is ${value} seconds" sendEvent(name: "pushTime", value: value, descriptionText: descriptionText) logInfo "${descriptionText}" } else { logDebug "Tuya cmd: dp=${dp} value=${fncmd} descMap.data = ${descMap?.data}" } break case 0x68 : // (104) if (isFingerbot()) { def value = FingerbotDirectionOpts.options[fncmd as int] def descriptionText = "Fingerbot switch direction is ${value} (${fncmd})" sendEvent(name: "direction", value: value, descriptionText: descriptionText) logInfo "${descriptionText}" } else { logDebug "Tuya cmd: dp=${dp} value=${fncmd} descMap.data = ${descMap?.data}" } break case 0x69 : // (105) if (isFingerbot()) { //logInfo "Fingerbot Battery Power is ${fncmd}" sendBatteryPercentageEvent(fncmd) } else { logDebug "Tuya cmd: dp=${dp} value=${fncmd} descMap.data = ${descMap?.data}" } break case 0x6A : // (106) if (isFingerbot()) { def value = fncmd as int def descriptionText = "Fingerbot Up Position is ${value} %" sendEvent(name: "upPosition", value: value, descriptionText: descriptionText) logInfo "${descriptionText}" } else { logDebug "Tuya cmd: dp=${dp} value=${fncmd} descMap.data = ${descMap?.data}" } break case 0x6B : // (107) logInfo "Fingerbot Tact Switch is ${fncmd}" break case 0x6C : // (108) logInfo "Fingerbot Click is ${fncmd}" break case 0x6D : // (109) logInfo "Fingerbot Custom Program is ${fncmd}" break case 0x6E : // (110) logInfo "Fingerbot Producion Test is ${fncmd}" break case 0x6F : // (111) logInfo "Fingerbot Sports Statistics is ${fncmd}" break case 0x70 : // (112) logInfo "Fingerbot Custom Timing is ${fncmd}" break default : logWarn "NOT PROCESSED Tuya cmd: dp=${dp} value=${fncmd} descMap.data = ${descMap?.data}" break } } private int getTuyaAttributeValue(ArrayList _data, index) { int retValue = 0 if (_data.size() >= 6) { int dataLength = _data[5+index] as Integer int power = 1; for (i in dataLength..1) { retValue = retValue + power * zigbee.convertHexToInt(_data[index+i+5]) power = power * 256 } } return retValue } private sendTuyaCommand(dp, dp_type, fncmd) { ArrayList cmds = [] def ep = safeToInt(state.destinationEP) if (ep==null || ep==0) ep = 1 def tuyaCmd = isFingerbot() ? 0x04 : SETDATA cmds += zigbee.command(CLUSTER_TUYA, tuyaCmd, [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 zigbee.convertToHexString(new Random().nextInt(65536), 4) } def tuyaTest( dpCommand, dpValue, dpTypeString ) { ArrayList cmds = [] def dpType = dpTypeString=="DP_TYPE_VALUE" ? DP_TYPE_VALUE : dpTypeString=="DP_TYPE_BOOL" ? DP_TYPE_BOOL : dpTypeString=="DP_TYPE_ENUM" ? DP_TYPE_ENUM : null def dpValHex = dpTypeString=="DP_TYPE_VALUE" ? zigbee.convertToHexString(dpValue as int, 8) : dpValue if (settings?.logEnable) log.warn "${device.displayName} sending TEST command=${dpCommand} value=${dpValue} ($dpValHex) type=${dpType}" sendZigbeeCommands( sendTuyaCommand(dpCommand, dpType, dpValHex) ) } private getANALOG_INPUT_BASIC_CLUSTER() { 0x000C } private getANALOG_INPUT_BASIC_PRESENT_VALUE_ATTRIBUTE() { 0x0055 } 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) } void aqaraBlackMagic() { List cmds = [] if (isAqaraTVOC() || isAqaraTRV()) { cmds += ["he raw 0x${device.deviceNetworkId} 0 0 0x8002 {40 00 00 00 00 40 8f 5f 11 52 52 00 41 2c 52 00 00} {0x0000}", "delay 200",] cmds += "zdo bind 0x${device.deviceNetworkId} 0x01 0x01 0xFCC0 {${device.zigbeeId}} {}" cmds += "zdo bind 0x${device.deviceNetworkId} 0x01 0x01 0x0406 {${device.zigbeeId}} {}" cmds += zigbee.readAttribute(0x0001, 0x0020, [:], delay=200) // TODO: check - battery voltage if (isAqaraTVOC()) { cmds += zigbee.readAttribute(0xFCC0, [0x0102, 0x010C], [mfgCode: 0x115F], delay=200) // TVOC only } sendZigbeeCommands( cmds ) logDebug "sent aqaraBlackMagic()" } else { logDebug "aqaraBlackMagic() was SKIPPED" } } /** * initializes the device * Invoked from configure() * @return zigbee commands */ def initializeDevice() { ArrayList cmds = [] logInfo 'initializeDevice...' // TODO !!!!!! if (isAqaraTVOC()) { return zigbee.readAttribute(zigbee.POWER_CONFIGURATION_CLUSTER, 0x0020)+ zigbee.readAttribute(zigbee.TEMPERATURE_MEASUREMENT_CLUSTER, 0x0000)+ zigbee.readAttribute(zigbee.RELATIVE_HUMIDITY_MEASUREMENT_CLUSTER, 0x0000) + zigbee.readAttribute(ANALOG_INPUT_BASIC_CLUSTER, ANALOG_INPUT_BASIC_PRESENT_VALUE_ATTRIBUTE) + zigbee.configureReporting(zigbee.RELATIVE_HUMIDITY_MEASUREMENT_CLUSTER, 0x0000, DataType.UINT16, 30, 300, 1*100) + zigbee.configureReporting(zigbee.TEMPERATURE_MEASUREMENT_CLUSTER, 0x0000, DataType.INT16, 30, 300, 0x1) + zigbee.configureReporting(zigbee.POWER_CONFIGURATION_CLUSTER, 0x0020, DataType.UINT8, 30, 21600, 0x1) + zigbee.configureReporting(ANALOG_INPUT_BASIC_CLUSTER, ANALOG_INPUT_BASIC_PRESENT_VALUE_ATTRIBUTE, DataType.FLOAT4, 10, 3600, 5) } // Ikea VINDSTYRKA : bind clusters 402, 405, 42A (PM2.5) int intMinTime = 10 int intMaxTime = 120 int intDelta = 2 String epString = getDestinationEP() if (isAqaraTVOC()) { cmds += zigbee.readAttribute(zigbee.POWER_CONFIGURATION_CLUSTER, 0x0020) cmds += zigbee.readAttribute(zigbee.TEMPERATURE_MEASUREMENT_CLUSTER, 0x0000) cmds += zigbee.readAttribute(zigbee.RELATIVE_HUMIDITY_MEASUREMENT_CLUSTER, 0x0000) cmds += zigbee.readAttribute(ANALOG_INPUT_BASIC_CLUSTER, ANALOG_INPUT_BASIC_PRESENT_VALUE_ATTRIBUTE) } if (DEVICE_TYPE in ["THSensor", "AirQuality"]) { if (isAqaraTVOC()) { // TODO !!! //zigbee.configureReporting(zigbee.RELATIVE_HUMIDITY_CLUSTER, 0x0000, DataType.UINT16, 30, 300, 1*100) + //zigbee.configureReporting(zigbee.TEMPERATURE_MEASUREMENT_CLUSTER, 0x0000, DataType.INT16, 30, 300, 0x1) + } //cmds += ["zdo bind 0x${device.deviceNetworkId} 0x${epString} 0x01 {${device.zigbeeId}} {}", "delay 251", ] //cmds += ["he cr 0x${device.deviceNetworkId} 0x${epString} 0x0402 0 16 ${intMinTime} ${intMaxTime} {}", "delay 251", ] cmds += zigbee.configureReporting(zigbee.TEMPERATURE_MEASUREMENT_CLUSTER, 0 /*TEMPERATURE_MEASUREMENT_MEASURED_VALUE_ATTRIBUTE*/, DataType.INT16, 15, 300, 100 /* 100=0.1도*/) // 402 - temperature cmds += zigbee.configureReporting(zigbee.RELATIVE_HUMIDITY_MEASUREMENT_CLUSTER, 0 /*RALATIVE_HUMIDITY_MEASUREMENT_MEASURED_VALUE_ATTRIBUTE*/, DataType.UINT16, 15, 300, 400/*10/100=0.4%*/) // 405 - humidity } if (DEVICE_TYPE in ["AirQuality"]) { if (isAqaraTVOC()) { cmds += zigbee.configureReporting(zigbee.POWER_CONFIGURATION_CLUSTER, 0x0020, DataType.UINT8, 30, 21600, 0x1) cmds += zigbee.configureReporting(ANALOG_INPUT_BASIC_CLUSTER, ANALOG_INPUT_BASIC_PRESENT_VALUE_ATTRIBUTE, DataType.FLOAT4, 10, 3600, 5) } else { cmds += zigbee.configureReporting(0x042a, 0, 0x39, 30, 60, 1) // 405 - pm2.5 //cmds += zigbee.configureReporting(0xfc7e, 0, 0x39, 10, 60, 50) // provides a measurement in the range of 0-500 that correlates with the tVOC trend display on the unit itself. cmds += ["zdo unbind 0x${device.deviceNetworkId} 0x${device.endpointId} 0x01 0xfc7e {${device.zigbeeId}} {}", "delay 251", ] } } // if (cmds == []) { cmds = ["delay 299",] } return cmds } /** * configures the device * Invoked from updated() * @return zigbee commands */ def configureDevice() { ArrayList cmds = [] logInfo 'configureDevice...' if ((DEVICE_TYPE in ["AirQuality"]) && isAqaraTVOC()) { // https://forum.phoscon.de/t/aqara-tvoc-zhaairquality-data/1160/21 //TemperatureScaleOpts /* 0b00000: 'mgm3_celsius', 0b00001: 'ppb_celsius', 0b10000: 'mgm3_fahrenheit', 0b10001: 'ppb_fahrenheit', */ final int tScale = (settings.temperatureScale as Integer) ?: TemperatureScaleOpts.defaultValue final int tUnit = (settings.tVocUnut as Integer) ?: TvocUnitOpts.defaultValue logDebug "setting temperatureScale to ${TemperatureScaleOpts.options[tScale]} (${tScale})" int cfg = tUnit cfg |= (tScale << 4) cmds += zigbee.writeAttribute(0xFCC0, 0x0114, DataType.UINT8, cfg, [mfgCode: 0x115F], delay=200) cmds += zigbee.readAttribute(0xFCC0, 0x0114, [mfgCode: 0x115F], delay=200) } if (DEVICE_TYPE in ["Fingerbot"]) { final int mode = (settings.fingerbotMode as Integer) ?: FingerbotModeOpts.defaultValue logDebug "setting fingerbotMode to ${FingerbotModeOpts.options[mode]} (${mode})" cmds = sendTuyaCommand("65", DP_TYPE_BOOL, zigbee.convertToHexString(mode as int, 2) ) final int duration = (settings.pushTime as Integer) ?: 0 logDebug "setting pushTime to ${duration} seconds)" cmds += sendTuyaCommand("67", DP_TYPE_VALUE, zigbee.convertToHexString(duration as int, 8) ) final int dnPos = (settings.dnPosition as Integer) ?: 0 logDebug "setting dnPosition to ${dnPos} %)" cmds += sendTuyaCommand("66", DP_TYPE_VALUE, zigbee.convertToHexString(dnPos as int, 8) ) final int upPos = (settings.upPosition as Integer) ?: 0 logDebug "setting upPosition to ${upPos} %)" cmds += sendTuyaCommand("6A", DP_TYPE_VALUE, zigbee.convertToHexString(upPos as int, 8) ) final int dir = (settings.direction as Integer) ?: FingerbotDirectionOpts.defaultValue logDebug "setting fingerbot direction to ${FingerbotDirectionOpts.options[dir]} (${dir})" cmds += sendTuyaCommand("68", DP_TYPE_BOOL, zigbee.convertToHexString(dir as int, 2) ) } // if (cmds == []) { cmds = ["delay 299",] } sendZigbeeCommands(cmds) } /* * ----------------------------------------------------------------------------- * Hubitat default handlers methods * ----------------------------------------------------------------------------- */ def refresh() { logInfo "refresh()..." checkDriverVersion() List cmds = [] if (state.states == null) state.states = [:] state.states["isRefresh"] = true if (DEVICE_TYPE in ["THSensor", "AirQuality"]) { cmds += zigbee.readAttribute(0x0402, 0x0000, [:], delay=200) cmds += zigbee.readAttribute(0x0405, 0x0000, [:], delay=200) } if (DEVICE_TYPE in ["AirQuality"]) { if (true) { // TODO - check what is available for VINDSTYRKA cmds += zigbee.readAttribute(0x042a, 0x0000, [:], delay=200) // pm2.5 attributes: (float) 0: Measured Value; 1: Min Measured Value; 2:Max Measured Value; 3:Tolerance cmds += zigbee.readAttribute(0xfc7e, 0x0000, [mfgCode: 0x117c], delay=200) // tVOC !! mfcode="0x117c" !! attributes: (float) 0: Measured Value; 1: Min Measured Value; 2:Max Measured Value; } else if (false) { // TODO - check what is available for Aqara } else { // TODO - unknown AirQuaility sensor - try all ?? } } if (DEVICE_TYPE in ["Thermostat"]) { // TODO - Aqara E1 specific refresh commands only 1 cmds += zigbee.readAttribute(0x0001, 0x0020, [:], delay=200) // battery voltage (E1 does not send percentage) //cmds += zigbee.readAttribute(0x0201, 0x0000, [:], delay=100) // local temperature //cmds += zigbee.readAttribute(0x0201, 0x0011, [:], delay=100) // cooling setpoint //cmds += zigbee.readAttribute(0x0201, 0x0012, [:], delay=100) // heating setpoint cmds += zigbee.readAttribute(0x0201, [0x0000, 0x0011, 0x0012, 0x001C], [:], delay=100) // local temperature, cooling setpoint, heating setpoint, system mode (enum8 ) //cmds += zigbee.readAttribute(0x0201, 0x0015, [:], delay=100) // min heat setpoint limit - Unsupported Attribute //cmds += zigbee.readAttribute(0x0201, 0x0016, [:], delay=100) // max heat setpoint limit = Unsupported Attribute //cmds += zigbee.readAttribute(0x0201, 0x0017, [:], delay=100) // min cool setpoint limit - Unsupported Attribute //cmds += zigbee.readAttribute(0x0201, 0x0018, [:], delay=100) // max cool setpoint limit - Unsupported Attribute //cmds += zigbee.readAttribute(0x0201, 0x0019, [:], delay=100) // min setpoint dead band ?- Unsupported Attribute //cmds += zigbee.readAttribute(0x0201, 0x001C, [:], delay=100) // system mode (enum8 ) //cmds += zigbee.readAttribute(0x0201, 0x001E, [:], delay=100) // Unsupported Attribute //cmds += zigbee.readAttribute(0x0201, 0x0020, [:], delay=100) // Unsupported Attribute //cmds += zigbee.readAttribute(0x0201, 0x0023, [:], delay=100) // hold temperature (enum) on/off - Unsupported Attribute //cmds += zigbee.readAttribute(0x0201, 0x0029, [:], delay=100) // thermostat running mode - Unsupported Attribute cmds += zigbee.readAttribute(0x0202, 0x0000, [:], delay=100) } runInMillis( REFRESH_TIMER, clearRefreshRequest, [overwrite: true]) // 3 seconds if (cmds != null && cmds != [] ) { sendZigbeeCommands(cmds) } } def clearRefreshRequest() { state.states["isRefresh"] = false } void sendInfoEvent(String info=null) { if (info == null) { logDebug "clearing the Info event" device.deleteCurrentState("$it") } else { logInfo "${info}" sendEvent(name: "Info", value: info, isDigital: true) } } def ping() { if (!(isAqaraTVOC())) { logInfo 'ping...' scheduleCommandTimeoutCheck() state.lastTx["pingTime"] = new Date().getTime() sendZigbeeCommands( zigbee.readAttribute(zigbee.BASIC_CLUSTER, 0x01, [:], 0) ) } else { // Aqara TVOC is sleepy or does not respond to the ping. logInfo "ping() command is not available for this sleepy device." sendRttEvent("n/a") } } /** * sends 'rtt'event (after a ping() command) * @param null: calculate the RTT in ms * value: send the text instead ('timeout', 'n/a', etc..) * @return none */ void sendRttEvent( String value=null) { def now = new Date().getTime() def timeRunning = now.toInteger() - (state.lastTx["pingTime"] ?: now).toInteger() def descriptionText = "Round-trip time is ${timeRunning} (ms)" if (value == null) { logInfo "${descriptionText}" sendEvent(name: "rtt", value: timeRunning, descriptionText: descriptionText, unit: "ms", isDigital: true) } else { descriptionText = "Round-trip time is ${value}" logInfo "${descriptionText}" sendEvent(name: "rtt", value: value, descriptionText: descriptionText, isDigital: true) } } /** * 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) { if (cluster != null) { return zigbee.clusterLookup(cluster.toInteger()) ?: "private cluster 0x${intToHexStr(cluster.toInteger())}" } else { logWarn "cluster is NULL!" return "NULL" } } private void scheduleCommandTimeoutCheck(int delay = COMMAND_TIMEOUT) { runIn(delay, 'deviceCommandTimeout') } void deviceCommandTimeout() { logWarn 'no response received (sleepy device or offline?)' sendRttEvent("timeout") } /** * Schedule a device health check * @param intervalMins interval in minutes */ private void scheduleDeviceHealthCheck(final int intervalMins, final int healthMethod) { if (healthMethod == 1 || healthMethod == 2) { String cron = getCron( intervalMins*60 ) schedule(cron, 'deviceHealthCheck') logDebug "deviceHealthCheck is scheduled every ${intervalMins} minutes" } else { logWarn "deviceHealthCheck is not scheduled!" unschedule('deviceHealthCheck') } } private void unScheduleDeviceHealthCheck() { unschedule('deviceHealthCheck') device.deleteCurrentState('healthStatus') logWarn "device health check is disabled!" } // called when any event was received from the Zigbee device in the parse() method. void setHealthStatusOnline() { if(state.health == null) { state.health = [:] } state.health['checkCtr3'] = 0 if (!((device.currentValue('healthStatus') ?: 'unknown') in ['online'])) { sendHealthStatusEvent('online') logInfo "is now online!" } } def deviceHealthCheck() { if (state.health == null) { state.health = [:] } def ctr = state.health['checkCtr3'] ?: 0 if (ctr >= PRESENCE_COUNT_THRESHOLD) { if ((device.currentValue("healthStatus") ?: 'unknown') != 'offline' ) { logWarn "not present!" sendHealthStatusEvent('offline') } } else { logDebug "deviceHealthCheck - online (notPresentCounter=${ctr})" } state.states['checkCtr3'] = ctr + 1 } void sendHealthStatusEvent(value) { def descriptionText = "healthStatus changed to ${value}" sendEvent(name: "healthStatus", value: value, descriptionText: descriptionText, isStateChange: true, isDigital: true) if (value == 'online') { logInfo "${descriptionText}" } else { if (settings?.txtEnable) { log.warn "${device.displayName}} ${descriptionText}" } } } /** * Scheduled job for polling device specific attribute(s) */ void autoPoll() { logDebug "autoPoll()..." checkDriverVersion() List cmds = [] if (state.states == null) state.states = [:] //state.states["isRefresh"] = true if (DEVICE_TYPE in ["AirQuality"]) { cmds += zigbee.readAttribute(0xfc7e, 0x0000, [mfgCode: 0x117c], delay=200) // tVOC !! mfcode="0x117c" !! attributes: (float) 0: Measured Value; 1: Min Measured Value; 2:Max Measured Value; } if (cmds != null && cmds != [] ) { sendZigbeeCommands(cmds) } } /** * Invoked by Hubitat when the driver configuration is updated */ void updated() { logInfo 'updated...' logInfo"driver version ${driverVersionAndTimeStamp()}" unschedule() if (settings.logEnable) { logDebug settings runIn(86400, logsOff) } final int healthMethod = (settings.healthCheckMethod as Integer) ?: 0 if (healthMethod == 1 || healthMethod == 2) { // [0: 'Disabled', 1: 'Activity check', 2: 'Periodic polling'] // schedule the periodic timer final int interval = (settings.healthCheckInterval as Integer) ?: 0 if (interval > 0) { //log.trace "healthMethod=${healthMethod} interval=${interval}" log.info "scheduling health check every ${interval} minutes by ${HealthcheckMethodOpts.options[healthCheckMethod as int]} method" scheduleDeviceHealthCheck(interval, healthMethod) } } else { unScheduleDeviceHealthCheck() // unschedule the periodic job, depending on the healthMethod log.info "Health Check is disabled!" } if (DEVICE_TYPE in ["AirQuality"]) { if (!(isAqaraTVOC())) { final int intervalAirQuality = (settings.airQualityIndexCheckInterval as Integer) ?: 0 if (intervalAirQuality > 0) { log.info "scheduling Air Quality Index check every ${intervalAirQuality} seconds" scheduleAirQualityIndexCheck(intervalAirQuality) } else { unScheduleAirQualityIndexCheck() log.info "Air Quality Index polling is disabled!" } } else { logDebug "skipping airQuality polling" } } configureDevice() // sends Zigbee commands sendInfoEvent("updated") } /** * Disable logging (for debugging) */ void logsOff() { logInfo 'debug logging disabled...' device.updateSetting('logEnable', [value: 'false', type: 'bool']) } /** * Send configuration parameters to the device * Invoked when device is first installed and when the user updates the configuration * @return sends zigbee commands */ def configure() { ArrayList cmds = [] logInfo 'configure...' logDebug settings cmds += tuyaBlackMagic() if (isAqaraTVOC() || isAqaraTRV()) { aqaraBlackMagic() } cmds += initializeDevice() cmds += configureDevice() sendZigbeeCommands(cmds) } /** * Invoked by Hubitat when driver is installed */ void installed() { logInfo 'installed...' // populate some default values for attributes sendEvent(name: 'healthStatus', value: 'unknown') sendEvent(name: 'powerSource', value: 'unknown') sendInfoEvent("installed") runIn(3, 'updated') } /** * Invoked when initialize button is clicked */ void initialize() { logInfo 'initialize...' initializeVars(fullInit = true) } /* *----------------------------------------------------------------------------- * kkossev drivers commonly used functions *----------------------------------------------------------------------------- */ static Integer safeToInt(val, Integer defaultVal=0) { return "${val}"?.isInteger() ? "${val}".toInteger() : defaultVal } static Double safeToDouble(val, Double defaultVal=0.0) { return "${val}"?.isDouble() ? "${val}".toDouble() : defaultVal } void sendZigbeeCommands(ArrayList cmd) { logDebug "sendZigbeeCommands(cmd=$cmd)" hubitat.device.HubMultiAction allActions = new hubitat.device.HubMultiAction() cmd.each { allActions.add(new hubitat.device.HubAction(it, hubitat.device.Protocol.ZIGBEE)) if (state.stats != null) state.stats['txCtr'] = (state.stats['txCtr'] ?: 0) + 1 else state.stats=[:] } sendHubCommand(allActions) } static def driverVersionAndTimeStamp() {version() + ' ' + timeStamp() + ((_DEBUG) ? " (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) { logDebug "updating the settings from the current driver version ${state.driverVersion} to the new version ${driverVersionAndTimeStamp()}" state.driverVersion = driverVersionAndTimeStamp() } else { // no driver version change } } /** * called from TODO * */ def deleteAllStatesAndJobs() { state.clear() // clear all states unschedule() device.deleteCurrentState('*') device.deleteCurrentState('') log.info "${device.displayName} jobs and states cleared. HE hub is ${getHubVersion()}, version is ${location.hub.firmwareVersionString}" } /** * called from TODO * */ def resetStats() { state.stats = [:] state.states = [:] state.lastRx = [:] state.lastTx = [:] state.health = [:] state.stats["rxCtr"] = 0 state.stats["txCtr"] = 0 state.states["isDigital"] = false state.states["isRefresh"] = false state.health["offlineCtr"] = 0 state.health["checkCtr3"] = 0 } /** * called from TODO * */ void initializeVars( boolean fullInit = false ) { logInfo "InitializeVars()... fullInit = ${fullInit}" if (fullInit == true ) { state.clear() unschedule() resetStats() //setDeviceNameAndProfile() //state.comment = 'Works with Tuya Zigbee Devices' logInfo "all states and scheduled jobs cleared!" state.driverVersion = driverVersionAndTimeStamp() log.trace "deviceType = ${deviceType} DEVICE_TYPE = ${DEVICE_TYPE}" state.deviceType = DEVICE_TYPE } if (state.stats == null) { state.stats = [:] } if (state.states == null) { state.states = [:] } if (state.lastRx == null) { state.lastRx = [:] } if (state.lastTx == null) { state.lastTx = [:] } if (state.health == null) { state.health = [:] } if (fullInit || state.states["notPresentCtr"] == null) state.states["notPresentCtr"] = 0 if (fullInit || settings?.logEnable == null) device.updateSetting("logEnable", true) if (fullInit || settings?.txtEnable == null) device.updateSetting("txtEnable", true) if (fullInit || settings?.advancedOptions == null) device.updateSetting("advancedOptions", [value:false, type:"bool"]) if (fullInit || settings?.healthCheckMethod == null) device.updateSetting('healthCheckMethod', [value: HealthcheckMethodOpts.defaultValue.toString(), type: 'enum']) if (fullInit || settings?.healthCheckInterval == null) device.updateSetting('healthCheckInterval', [value: HealthcheckIntervalOpts.defaultValue.toString(), type: 'enum']) if (fullInit || settings?.TemperatureScaleOpts == null) device.updateSetting('temperatureScale', [value: TemperatureScaleOpts.defaultValue.toString(), type: 'enum']) if (fullInit || settings?.tVocUnut == null) device.updateSetting('tVocUnut', [value: TvocUnitOpts.defaultValue.toString(), type: 'enum']) if (DEVICE_TYPE in ["AirQuality"]) { if (fullInit || settings?.airQualityIndexCheckInterval == null) device.updateSetting('airQualityIndexCheckInterval', [value: AirQualityIndexCheckIntervalOpts.defaultValue.toString(), type: 'enum']) } if (DEVICE_TYPE in ["Fingerbot"]) { if (fullInit || settings?.fingerbotMode == null) device.updateSetting('fingerbotMode', [value: FingerbotModeOpts.defaultValue.toString(), type: 'enum']) if (fullInit || settings?.pushTime == null) device.updateSetting("pushTime", [value:0, type:"number"]) if (fullInit || settings?.upPosition == null) device.updateSetting("upPosition", [value:0, type:"number"]) if (fullInit || settings?.dnPosition == null) device.updateSetting("dnPosition", [value:100, type:"number"]) } if (device.currentValue('healthStatus') == null) sendHealthStatusEvent('unknown') //updateTuyaVersion() def mm = device.getDataValue("model") if ( mm != null) { if (logEnable==true) log.trace " model = ${mm}" } else { if (txtEnable==true) log.warn " Model not found, please re-pair the device!" } def ep = device.getEndpointId() if ( ep != null) { //state.destinationEP = ep if (logEnable==true) log.trace " destinationEP = ${ep}" } else { if (txtEnable==true) log.warn " Destination End Point not found, please re-pair the device!" //state.destinationEP = "01" // fallback } } /** * called from TODO * */ 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.logEnable) { log.debug "${device.displayName} " + msg } } def logInfo(msg) { if (settings.txtEnable) { log.info "${device.displayName} " + msg } } def logWarn(msg) { if (settings.logEnable) { log.warn "${device.displayName} " + msg } } void getAllProperties() { log.trace 'Properties:' device.properties.each { it-> log.debug it } log.trace 'Settings:' settings.each { it-> log.debug "${it.key} = ${it.value}" // https://community.hubitat.com/t/how-do-i-get-the-datatype-for-an-app-setting/104228/6?u=kkossev } log.trace 'Done' } // delete all Preferences void deleteAllSettings() { settings.each { it-> log.debug "deleting ${it.key}" this.removeSetting("${it.key}") } log.trace settings log.trace 'Done' } // delete all attributes void deleteAllCurrentStates() { device.properties.supportedAttributes.each { it-> log.debug "deleting $it" device.deleteCurrentState("$it") } log.trace 'Done' } // delete all State Variables void deleteAllStates() { state.each { it-> log.debug "deleting state ${it.key}" } state.clear() log.trace 'Done' } void deleteAllScheduledJobs() { unschedule() log.trace 'Done' } def parseTest(par) { //read attr - raw: DF8D0104020A000029280A, dni: DF8D, endpoint: 01, cluster: 0402, size: 0A, attrId: 0000, encoding: 29, command: 0A, value: 280A log.warn "parseTest(${par})" parse(par) } def testJob() { log.warn "test job executed" } /** * Calculates and returns the cron expression * @param timeInSeconds interval in seconds */ def getCron( timeInSeconds ) { //schedule("${rnd.nextInt(59)} ${rnd.nextInt(9)}/${intervalMins} * ? * * *", 'ping') // TODO: runEvery1Minute runEvery5Minutes runEvery10Minutes runEvery15Minutes runEvery30Minutes runEvery1Hour runEvery3Hours final Random rnd = new Random() def minutes = (timeInSeconds / 60 ) as int def hours = (minutes / 60 ) as int if (hours > 23) { hours = 23 } String cron if (timeInSeconds < 60) { cron = "*/$timeInSeconds * * * * ? *" } else { if (minutes < 60) { cron = "${rnd.nextInt(59)} ${rnd.nextInt(9)}/$minutes * ? * *" } else { cron = "${rnd.nextInt(59)} ${rnd.nextInt(59)} */$hours ? * *" } } return cron } def test(par) { /* cluster 0xfcc0 / attribute 0x0114 (display on the device): bit 1: mg/m³ or ppb (unset, set) bit 2: temperature °C/°F (unset, set) */ ArrayList cmds = [] log.warn 'test...' final int mode = (settings.fingerbotMode as Integer) ?: FingerbotModeOpts.defaultValue logDebug "setting fingerbotMode to ${FingerbotModeOpts.options[mode]} (${mode})" cmds = sendTuyaCommand("01", DP_TYPE_ENUM, zigbee.convertToHexString(mode as int, 2) ) sendZigbeeCommands(cmds) }