/* groovylint-disable CompileStatic, CouldBeSwitchStatement, DuplicateListLiteral, DuplicateNumberLiteral, DuplicateStringLiteral, ImplicitClosureParameter, ImplicitReturnStatement, Instanceof, LineLength, MethodCount, MethodSize, NoDouble, NoFloat, NoJavaUtilDate, NoWildcardImports, ParameterCount, ParameterName, PublicMethodsBeforeNonPublicMethods, UnnecessaryElseStatement, UnnecessaryGetter, UnnecessaryObjectReferences, UnnecessaryPublicModifier, UnnecessarySetter, UnusedImport */
library(
base: 'driver', author: 'Krassimir Kossev', category: 'zigbee', description: 'Zigbee Battery Library', name: 'batteryLib', namespace: 'kkossev',
importUrl: 'https://raw.githubusercontent.com/kkossev/Hubitat/refs/heads/development/Libraries/batteryLib.groovy', documentationLink: 'https://github.com/kkossev/Hubitat/wiki/libraries-batteryLib',
version: '3.2.3'
)
/*
* Zigbee Battery Library
*
* Licensed Virtual the Apache License, Version 2.0
*
* ver. 3.0.0 2024-04-06 kkossev - added batteryLib.groovy
* ver. 3.0.1 2024-04-06 kkossev - customParsePowerCluster bug fix
* ver. 3.0.2 2024-04-14 kkossev - batteryPercentage bug fix (was x2); added bVoltCtr; added battertRefresh
* ver. 3.2.0 2024-05-21 kkossev - commonLib 3.2.0 allignment; added lastBattery; added handleTuyaBatteryLevel
* ver. 3.2.1 2024-07-06 kkossev - added tuyaToBatteryLevel and handleTuyaBatteryLevel; added batteryInitializeVars
* ver. 3.2.2 2024-07-18 kkossev - added BatteryVoltage and BatteryDelay device capability checks
* ver. 3.2.3 2025-07-13 kkossev - bug fix: corrected runIn method name from 'sendDelayedBatteryEvent' to 'sendDelayedBatteryPercentageEvent'
*
* TODO: add an Advanced Option resetBatteryToZeroWhenOffline
* TODO: battery voltage low/high limits configuration
*/
static String batteryLibVersion() { '3.2.3' }
static String batteryLibStamp() { '2025/07/13 7:45 PM' }
metadata {
capability 'Battery'
attribute 'batteryVoltage', 'number'
attribute 'lastBattery', 'date' // last battery event time - added in 3.2.0 05/21/2024
// no commands
preferences {
if (device && advancedOptions == true) {
if ('BatteryVoltage' in DEVICE?.capabilities) {
input name: 'voltageToPercent', type: 'bool', title: 'Battery Voltage to Percentage', defaultValue: false, description: 'Convert battery voltage to battery Percentage remaining.'
}
if ('BatteryDelay' in DEVICE?.capabilities) {
input(name: 'batteryDelay', type: 'enum', title: 'Battery Events Delay', description:'Select the Battery Events Delay
(default is no delay)', options: DelayBatteryOpts.options, defaultValue: DelayBatteryOpts.defaultValue)
}
}
}
}
@Field static final Map DelayBatteryOpts = [ defaultValue: 0, options: [0: 'No delay', 30: '30 seconds', 3600: '1 hour', 14400: '4 hours', 28800: '8 hours', 43200: '12 hours']]
public void standardParsePowerCluster(final Map descMap) {
if (descMap.value == null || descMap.value == 'FFFF') { return } // invalid or unknown value
final int rawValue = hexStrToUnsignedInt(descMap.value)
if (descMap.attrId == '0020') { // battery voltage
state.lastRx['batteryTime'] = new Date().getTime()
state.stats['bVoltCtr'] = (state.stats['bVoltCtr'] ?: 0) + 1
sendBatteryVoltageEvent(rawValue)
if ((settings.voltageToPercent ?: false) == true) {
sendBatteryVoltageEvent(rawValue, convertToPercent = true)
}
}
else if (descMap.attrId == '0021') { // battery percentage
state.lastRx['batteryTime'] = new Date().getTime()
state.stats['battCtr'] = (state.stats['battCtr'] ?: 0) + 1
if (isTuya()) {
sendBatteryPercentageEvent(rawValue)
}
else {
sendBatteryPercentageEvent((rawValue / 2) as int)
}
}
else {
logWarn "customParsePowerCluster: zigbee received unknown Power cluster attribute 0x${descMap.attrId} (value ${descMap.value})"
}
}
public void sendBatteryVoltageEvent(final int rawValue, boolean convertToPercent=false) {
logDebug "batteryVoltage = ${(double)rawValue / 10.0} V"
final Date lastBattery = new Date()
Map result = [:]
BigDecimal volts = safeToBigDecimal(rawValue) / 10G
if (rawValue != 0 && rawValue != 255) {
BigDecimal minVolts = 2.2
BigDecimal maxVolts = 3.2
BigDecimal pct = (volts - minVolts) / (maxVolts - minVolts)
int 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)
sendEvent(name: 'lastBattery', value: lastBattery)
}
else {
logWarn "ignoring BatteryResult(${rawValue})"
}
}
public void sendBatteryPercentageEvent(final int batteryPercent, boolean isDigital=false) {
if ((batteryPercent as int) == 255) {
logWarn "ignoring battery report raw=${batteryPercent}"
return
}
final Date lastBattery = new Date()
Map 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
//
Object latestBatteryEvent = device.currentState('battery')
Long latestBatteryEventTime = latestBatteryEvent != null ? latestBatteryEvent.getDate().getTime() : now()
//log.debug "battery latest state timeStamp is ${latestBatteryTime} now is ${now()}"
int 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)
sendEvent(name: 'lastBattery', value: lastBattery)
}
else {
int delayedTime = (settings?.batteryDelay as int) - timeDiff
map.delayed = delayedTime
map.descriptionText += " [delayed ${map.delayed} seconds]"
map.lastBattery = lastBattery
logDebug "this battery event (${map.value}%) will be delayed ${delayedTime} seconds"
runIn(delayedTime, 'sendDelayedBatteryPercentageEvent', [overwrite: true, data: map])
}
}
private void sendDelayedBatteryPercentageEvent(Map map) {
logInfo "${map.descriptionText}"
//map.each {log.trace "$it"}
sendEvent(map)
sendEvent(name: 'lastBattery', value: map.lastBattery)
}
/* groovylint-disable-next-line UnusedPrivateMethod */
private void sendDelayedBatteryVoltageEvent(Map map) {
logInfo "${map.descriptionText}"
//map.each {log.trace "$it"}
sendEvent(map)
sendEvent(name: 'lastBattery', value: map.lastBattery)
}
public int tuyaToBatteryLevel(int fncmd) {
int rawValue = fncmd
switch (fncmd) {
case 0: rawValue = 100; break // Battery Full
case 1: rawValue = 75; break // Battery High
case 2: rawValue = 50; break // Battery Medium
case 3: rawValue = 25; break // Battery Low
case 4: rawValue = 100; break // Tuya 3 in 1 -> USB powered
// for all other values >4 we will use the raw value, expected to be the real battery level 4..100%
}
return rawValue
}
public void handleTuyaBatteryLevel(int fncmd) {
int rawValue = tuyaToBatteryLevel(fncmd)
sendBatteryPercentageEvent(rawValue)
}
public void batteryInitializeVars( boolean fullInit = false ) {
logDebug "batteryInitializeVars()... fullInit = ${fullInit}"
if (device.hasCapability('Battery')) {
if (fullInit || settings?.voltageToPercent == null) { device.updateSetting('voltageToPercent', false) }
if (fullInit || settings?.batteryDelay == null) { device.updateSetting('batteryDelay', [value: DelayBatteryOpts.defaultValue.toString(), type: 'enum']) }
}
}
public List batteryRefresh() {
List cmds = []
cmds += zigbee.readAttribute(0x0001, 0x0020, [:], delay = 100) // battery voltage
cmds += zigbee.readAttribute(0x0001, 0x0021, [:], delay = 100) // battery percentage
return cmds
}