/** * Spruce Sensor -updated with SLP3 model number 3/2019 * * Copyright 2019 Plaid Systems * * 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. * -------10/20/2015 Updates-------- -Fix/add battery reporting interval to update -remove polling and/or refresh -------5/2017 Updates-------- -Add fingerprints for SLP -add device health, check every 60mins + 2mins -------3/2019 Updates-------- -Add fingerprints for SLP3 -change device health from 62mins to 3 hours ^^^^^^^^^^^^ SmartThings Update Log ^^^^^^^^^^^^ -------11/2019 Updates-------- -port to hubitat -------12/2019 Updates-------- - Refactored to Hubitat "standards" - debugOn, infoOn both on at install - debugOff after 1 hour - use referenceTemp to calculate tempOffset from state.sensorTemp - move tempOffset attribute to state.tempOffset - addeed importUrl - Changed all rounding to use BigDecimal.ROUND_HALF_UP - Globally preserve 2 decimal points of temperature precision for both Celsius and Fahrenheit (was C only) - Added Notes preference/state variable for Location, Battery Change Date, etc. - Eliminate consecutive duplicate events - removed debug refreshAttributes() - stop creating new events for duplicate values recieved within 10 seconds of each other - added setInterval command, call update() and then poll() */ metadata { definition (name: "Spruce Sensor", namespace: "plaidsystems", author: "Plaid Systems", importUrl: "https://raw.githubusercontent.com/PlaidSystems/Spruce-Hubitat/master/drivers/Spruce%20sensor.src") { capability "Configuration" capability "Battery" capability "Relative Humidity Measurement" capability "Temperature Measurement" capability "Sensor" attribute "maxHum", "string" attribute "minHum", "string" attribute "interval", "number" attribute "update", "number" command "resetHumidity", [] command "setInterval", [[name: "Measurement Interval*", type: "NUMBER", description: "Set sensor update frequency (minutes, 1-120)"]] fingerprint profileId: "0104", inClusters: "0000,0001,0003,0402,0405", outClusters: "0003, 0019", manufacturer: "PLAID SYSTEMS", model: "PS-SPRZMS-01", deviceJoinName: "Spruce Sensor" fingerprint profileId: "0104", inClusters: "0000,0001,0003,0402,0405", outClusters: "0003, 0019", manufacturer: "PLAID SYSTEMS", model: "PS-SPRZMS-SLP1", deviceJoinName: "Spruce Sensor" fingerprint profileId: "0104", inClusters: "0000,0001,0003,0402,0405", outClusters: "0003, 0019", manufacturer: "PLAID SYSTEMS", model: "PS-SPRZMS-SLP3", deviceJoinName: "Spruce Sensor" } preferences { input "referenceTemp", "decimal", title: "Reference temperature", description: "Enter current reference temperature reading", displayDuringSetup: false input "interval", "number", title: "Measurement Interval", description: "Set how often you would like to check soil moisture in minutes, 1-120 minutes (default: 10 minutes)", range: "1..120", defaultValue: 10, displayDuringSetup: false input "notes", "text", title: "Notes", description: "Sensor location, battery change date, zone assignment, etc.", displayDuringSetup: false input "debugOn", "bool", title: "Enable debug logging for 1 hour", defaultValue: true input "infoOn", "bool", title: "Enable descriptionText logging?", defaultValue: true } } def installed(){ initialize() } //when device preferences are changed def updated(){ unschedule() initialize() } def initialize() { def linkText = getLinkText(device) if (infoOn) log.info "${linkText} descriptionText logging enabled" if (debugOn) log.info "${linkText} debug logging enabled for 1 hour" log.info "${linkText} updated, settings: ${settings}" String descriptionText = "${linkText} settings changed, will update sensor at next report. Measurement Interval set to ${settings.interval} minutes, update state is 1" sendEvent(name: 'update', value: 1, descriptionText: descriptionText) sendEvent(name: 'interval', value: settings.interval, descriptionText: "${linkText} Measurement Interval updated") if (infoOn) log.info descriptionText // handle reference temperature / tempOffset automation if (settings.referenceTemp != null) { if (state.sensorTemp) { state.sensorTemp = roundIt(state.sensorTemp, 2) state.tempOffset = roundIt(referenceTemp - state.sensorTemp, 2) settings.referenceTemp = null device.clearSetting('referenceTemp') if (debugOn) log.debug "sensorTemp: ${state.sensorTemp}, referenceTemp: ${referenceTemp}, offset: ${state.tempOffset}" sendEvent(getTemperatureResult(state.sensorTemp)) } // else, preserve settings.referenceTemp, state.tempOffset will be calculate on the next temperature report } else if (state.tempOffset == null) { // Initialize the offset, converting from the old attribute-based approach if necessary def offset = device.currentValue('tempOffset') if (offset != null) { log.info "${linkText} One-time tempOffset conversion completed" sendEvent(name: 'tempOffset', value: null, descriptionText: "One-time tempOffset conversion completed") device.clearSetting('tempOffset') state.tempOffset = roundIt(offset, 2) if (state.sensorTemp) sendEvent(getTemperatureResult(state.sensorTemp)) } else { state.tempOffset = 0.0 } } state.Notes = notes if (debugOn) { // turn off debug logging after 1 hour runIn(3600, debugOff, [overwrite: true]) } } //parse events def parse(String description) { if (debugOn) log.debug "Parse description $description" Map map = [:] if (description?.startsWith('catchall:')) { map = parseCatchAllMessage(description) } else if (description?.startsWith('read attr -')) { map = parseReportAttributeMessage(description) } else if (description?.startsWith('temperature: ') || description?.startsWith('humidity: ')) { map = parseCustomMessage(description) } def result = map ? createEvent(map) : null //check in configuration change if (device.latestValue('update') != 0){ result = poll() def linkText = getLinkText(device) sendEvent(name: 'update', value: 0, descriptionText: "${linkText} update state is 0") if (infoOn) log.info "${linkText} update state is 0" } if (debugOn) log.debug "parse: result: $result" return result } private Map parseCatchAllMessage(String description) { Map resultMap = [:] def linkText = getLinkText(device) def descMap = zigbee.parse(description) def descMapParse = zigbee.parseDescriptionAsMap(description) if (debugOn) log.debug "parseCatchAllMessage: descMapParse -> ${descMapParse}" if (debugOn) log.debug "parseCatchAllMessage: descMap -> ${descMap}" //check humidity configuration is complete if (descMap.command == 0x07 && descMap.clusterId == 0x0405){ sendEvent(name: 'update', value: 0, descriptionText: "${linkText} update state is 0") if (infoOn) log.info "${linkText} update state is 0" if (debugOn) log.debug "config complete" } else if (descMap.command == 0x0001){ def hexString = "${hex(descMap.data[5])}" + "${hex(descMap.data[4])}" def intString = Integer.parseInt(hexString, 16) if (descMap.clusterId == 0x0402){ def value = getTemperature(hexString) resultMap = getTemperatureResult(value) } else if (descMap.clusterId == 0x0405){ def value = roundIt(new BigDecimal(intString / 100), 0) if (value != null) resultMap = getHumidityResult(value) } else return [:] } else return [:] if (infoOn && resultMap?.descriptionText) log.info resultMap.descriptionText return resultMap } private Map parseReportAttributeMessage(String description) { def descMap = zigbee.parseDescriptionAsMap(description) def descMapParse = zigbee.parse(description) if (debugOn) log.debug "Desc Map: $descMap" if (debugOn) log.debug "Report Attributes" Map resultMap = [:] if (descMap.cluster == "0402" && descMap.attrId == "0000") { def value = getTemperature(descMap.value) resultMap = getTemperatureResult(value) } else if (descMap.cluster == "0405" && descMap.attrId == "0000") { def intString = Integer.parseInt(descMap.value, 16) if (debugOn) log.debug "Raw Humidity: ${intString}" def value = roundIt(new BigDecimal(intString / 100.0), 0) if (value != null) resultMap = getHumidityResult(value) } else if (descMap.cluster == "0001" && descMap.attrId == "0000") { resultMap = getBatteryResult(descMap.value) } if (infoOn && resultMap?.descriptionText) { if ((resultMap.name == 'battery') && (resultMap.value < 10)) { log.warn resultMap.descriptionText } else log.info resultMap.descriptionText } return resultMap } def parseDescriptionAsMap(description) { (description - "read attr - ").split(",").inject([:]) { map, param -> def nameAndValue = param.split(":") map += [(nameAndValue[0].trim()):nameAndValue[1].trim()] } } private Map parseCustomMessage(String description) { Map resultMap = [:] if (debugOn) log.debug "parseCustom" if (description?.startsWith('temperature: ')) { def value = roundIt(zigbee.parseHATemperatureValue(description, "temperature: ", getTemperatureScale()), 2) // maintain 2 digits of precision if (debugOn) log.debug "Custom raw temperature: ${value}" resultMap = getTemperatureResult(value) } else if (description?.startsWith('humidity: ')) { def pct = (description - "humidity: " - "%").trim() if (debugOn) log.debug "Custom raw humidity: ${pct}" if (pct.isNumber()) { def value = roundIt(pct, 0) resultMap = getHumidityResult(value) } else { log.error "invalid humidity: ${pct}" } } if (infoOn && resultMap?.descriptionText) log.info resultMap.descriptionText return resultMap } private Map getHumidityResult(value) { def linkText = getLinkText(device) def maxHumValue = 0 def minHumValue = 0 if (device.currentValue("maxHum") != null) maxHumValue = device.currentValue("maxHum").toInteger() if (device.currentValue("minHum") != null) minHumValue = device.currentValue("minHum").toInteger() if (debugOn) log.debug "Humidity: ${value}, max: ${maxHumValue} min: ${minHumValue}" def compare = roundIt(value, 0) // value.toInteger() if (compare > maxHumValue) { sendEvent(name: 'maxHum', value: value, unit: '%', descriptionText: "${linkText} soil moisture high is ${value}%") } else if (((compare < minHumValue) || (minHumValue <= 2)) && (compare != 0)) { sendEvent(name: 'minHum', value: value, unit: '%', descriptionText: "${linkText} soil moisture low is ${value}%") } def nowDate = now() def lastDate = state.lastHumDate ?: 0 def currentValue = device.currentValue('humidity') if ((currentValue != value) || ((nowDate - lastDate) > 10000)) { // eliminate duplicate updates within 10 seconds of each other state.lastHumDate = nowDate return [ name: 'humidity', value: value, unit: '%', descriptionText: "${linkText} soil moisture is ${value}%" ] } else return [:] } def getTemperature(value) { def celsius = (Integer.parseInt(value, 16).shortValue()/100.0) if (debugOn) log.debug "Raw Temp ${value} : ${celsius}°C, ${celsiusToFahrenheit(celsius)}°F" if(location.temperatureScale == "C"){ if (state.sensorTemp != celsius) state.sensorTemp = celsius return celsius } else { def temp = roundIt(celsiusToFahrenheit(celsius), 2) // Keep 2 digits of precision if ((temp != null) && (state.sensorTemp != temp)) state.sensorTemp = temp return temp } } private Map getTemperatureResult(value) { if (debugOn) log.debug "getTemperatureResult(${value})" def linkText = getLinkText(device) if ((state.sensorTemp == null) || (state.sensorTemp != value)) state.sensorTemp = value if (settings.referenceTemp != null) { state.tempOffset = roundIt((referenceTemp - value), 2) settings.referenceTemp = null device.clearSetting('referenceTemp') if (debugOn) log.debug "sensorTemp: ${value}, referenceTemp: ${referenceTemp}, offset: ${state.tempOffset}" } def offset = state.tempOffset if (offset == null) { def temp = device.currentValue('tempOffset') // convert the old attribute to the new state variable offset = (temp != null) ? temp : 0.0 state.tempOffset = offset } if (offset != 0.0) { def v = value value = roundIt((v + offset), 2) } def nowDate = now() def lastDate = state.lastTempDate ?: 0 def currentValue = device.currentValue('temperature') if ((currentValue != value) || ((nowDate - lastDate) > 10000)) { // eliminate duplicate updates within 10 seconds of each other state.lastTempDate = nowDate return [ name: 'temperature', value: value, unit: temperatureScale, descriptionText: "${linkText} temperature is ${value}°${temperatureScale}" ] } else return [:] } private Map getBatteryResult(value) { if (debugOn) log.debug 'Battery' def linkText = getLinkText(device) def result = [ name: 'battery' ] def min = 2500 def percent = ((Integer.parseInt(value, 16) - min) / 5) percent = Math.max(0, Math.min(percent, 100.0)) result.value = roundIt(percent, 0) def nowDate = now() def lastDate = state.lastBattDate ?: 0 def currentValue = device.currentValue('battery') if ((currentValue != value) || ((nowDate - lastDate) > 10000)) { // eliminate duplicate updates within 10 seconds of each other state.lastBattDate = nowDate if (percent < 10) result.descriptionText = "${linkText} battery is ${result.value}% - VERY LOW" // Actually, it's probably dead at this point! else if (percent < 33) result.descriptionText = "${linkText} battery is ${result.value}% - getting low" else result.descriptionText = "${linkText} battery is ${result.value}% - OK" return result } else return [:] } def resetHumidity(){ def linkText = getLinkText(device) def minHumValue = 0 def maxHumValue = 0 String descriptionText = "${linkText} min soil moisture reset to ${minHumValue}%" sendEvent(name: 'minHum', value: minHumValue, unit: '%', descriptionText: descriptionText) if (infoOn) log.info descriptionText descriptionText = "${linkText} max soil moisture reset to ${maxHumValue}%" if (infoOn) log.info descriptionText sendEvent(name: 'maxHum', value: maxHumValue, unit: '%', descriptionText: descriptionText) } def setInterval(value) { if (value) { def newValue = roundIt(value,0).toInteger() device.updateSetting('interval', newValue) settings.interval = newValue updated() poll() } } //poll def poll() { if (debugOn) log.debug "poll called" List cmds = [] if (device.latestValue('update') == 2) cmds += configure() cmds += intervalUpdate() if (debugOn) log.debug "commands $cmds" return cmds?.collect { new hubitat.device.HubAction(it) } } //update intervals def intervalUpdate(){ if (debugOn) log.debug "intervalUpdate" def minReport = 10 def maxReport = 610 if (interval != null) { minReport = interval maxReport = interval * 61 } [ "zcl global send-me-a-report 0x405 0x0000 0x21 $minReport $maxReport {6400}", "delay 1000", "send 0x${device.deviceNetworkId} 1 1", "delay 1000", "zcl global send-me-a-report 1 0x0000 0x21 0x0C 0 {0500}", "delay 1000", "send 0x${device.deviceNetworkId} 1 1", "delay 1000", ] } def refresh() { if (debugOn) log.debug "refresh" [ "he rattr 0x${device.deviceNetworkId} 1 0x402 0", "delay 500", "he rattr 0x${device.deviceNetworkId} 1 0x405 0", "delay 500", "he rattr 0x${device.deviceNetworkId} 1 1 0" ] } //configure def configure() { //set minReport = measurement in minutes def minReport = 10 def maxReport = 610 // 10 hours and 10 minutes if (settings.interval != null) { minReport = settings.interval maxReport = settings.interval * 61 } if (debugOn) log.debug "zigbeeId ${device.zigbeeId}" def linkText = getLinkText(device) String descriptionText = "" if (!device.zigbeeId) { descriptionText = "${linkText} sensor's Zigbee Id not found, please remove and attempt to rejoin this sensor" log.warn descriptionText sendEvent(name: 'update', value: 0, descriptionText: descriptionText) //return [:] // can we still send the config commands below if we don't know the zigbeeId??? } else { descriptionText = "${linkText} configuration initialized" if (infoOn) log.info descriptionText + ", update state is 0" sendEvent(name: 'update', value: 0, descriptionText: descriptionText) } // this will take about 10 seconds, all-in [ "zdo bind 0x${device.deviceNetworkId} 1 1 0x402 {${device.zigbeeId}} {}", "delay 1000", "zdo bind 0x${device.deviceNetworkId} 1 1 0x405 {${device.zigbeeId}} {}", "delay 1000", "zdo bind 0x${device.deviceNetworkId} 1 1 1 {${device.zigbeeId}} {}", "delay 1000", //temperature "zcl global send-me-a-report 0x402 0x0000 0x29 1 0 {3200}", "delay 1000", "send 0x${device.deviceNetworkId} 1 1", "delay 1000", //min = soil measure interval "zcl global send-me-a-report 0x405 0x0000 0x21 $minReport $maxReport {6400}", "delay 1000", "send 0x${device.deviceNetworkId} 1 1", "delay 1000", //min = battery measure interval 1 = 1 hour "zcl global send-me-a-report 1 0x0000 0x21 0x0C 0 {0500}", "delay 1000", "send 0x${device.deviceNetworkId} 1 1", "delay 1000" ] + refresh() } def debugOff(){ log.warn "debug logging disabled..." device.updateSetting("debugOn",[value:"false",type:"bool"]) } private hex(value) { new BigInteger(Math.round(value).toString()).toString(16) } private String swapEndianHex(String hex) { reverseArray(hex.decodeHex()).encodeHex() } private byte[] reverseArray(byte[] array) { int i = 0; int j = array.length - 1; byte tmp; while (j > i) { tmp = array[j]; array[j] = array[i]; array[i] = tmp; j--; i++; } return array } def roundIt( value, decimals=0 ) { return (value == null) ? null : value.toBigDecimal().setScale(decimals, BigDecimal.ROUND_HALF_UP) } def roundIt( BigDecimal value, decimals=0 ) { return (value == null) ? null : value.setScale(decimals, BigDecimal.ROUND_HALF_UP) }