Battery Level Reminder How this app works:
Made By: WW & Claude Ai
• Checks every X hours starting from the user-specified time.
• Sends phone alerts if the battery is below the threshold
• Sends alerts including critical alerts for very low levels.
• Only alerts if the reading is fresh enough or has changed since the last check.
• Supports individual alerts with customizable maximum repetition control.
• Provides a daily phone summary report at the chosen time.
• Automatically resets counters when battery level returns above the threshold.
• Includes optional quiet hours to suppress notifications.
• Phone notifications can be turned on/off .
• Stale Devices in daily report and App Page.
• Offers optional logging.
Installing a Custom App (User-Created Code)
- Log into your Hubitat admin interface.
- Go to the "Apps Code" section under the "For Developers" menu
- (enable "Show advanced/developer options" in Settings if not visible).
- Click New App.
- Paste in the code you copied from below code.
- Click Save.
- Go to the Apps page → Add User App → select your new app.
- Configure it with your devices/settings.
definition(
name: "Battery Level Reminder V8",
namespace: "BLR V8 - WW + Claude",
author: "Warlock Weary + Claude",
description: "Notify every X hours if battery is low, with summary, critical alerts, quiet hours, snooze, styled status, and consistent logging",
category: "Convenience",
iconUrl: "",
iconX2Url: ""
)
preferences {
page(name: "mainPage", title: "<b>-= Battery Level Reminder Setup =-</b>", install: true, uninstall: true) {
section("<b></b>") {
paragraph """
<b>How this app works:</b>
• Checks battery levels every X hours starting from a specified time.
• Sends alerts for low or critical battery levels.
• Ensures readings are fresh or changed since last check.
• Provides a daily summary at a chosen time.
• Supports quiet hours and global snooze (1–7 days).
• Displays a styled status panel with low/critical counts, lowest battery, and last checked time.
"""
}
section("<hr style='background-color:#000000; height: 2px; border: 0;'><b>🎛️ Manual Control Buttons:</b>") {
input "checkNow", "button", title: "✅ Check Batteries Now"
input "sendSummaryNow", "button", title: "📜 Send Summary Now"
if (state.size() > 0) {
input "resetCounters", "button", title: "🗑️ Reset Notification Counters"
}
if (state.snoozeUntil && now() < (state.snoozeUntil as Long)) {
input "cancelSnooze", "button", title: "❌ Cancel Snooze"
}
}
// section("<b>📜 Status Summary</b>") {
section("<hr style='background-color:#000000; height: 2px; border: 0;'><b>📜 Status Summary:</b>") {
def deviceCount = devices?.size() ?: 0
def thresholdValue = (threshold ?: 50) as Integer
def criticalThresholdValue = (criticalThreshold ?: 25) as Integer
def freshnessHoursValue = (freshnessHours ?: 24) as Integer
def nextSummaryTime = summaryTime ? new Date(timeToday(summaryTime, location.timeZone).time).format('HH:mm dd-MMM', location.timeZone) : "Not set"
def checkIntervalValue = (interval ?: 4) as Integer
def checkStartTimeValue = checkStartTime ? new Date(timeToday(checkStartTime, location.timeZone).time).format('HH:mm', location.timeZone) : "Not set"
def maxNotifications = (repetitions == null) ? 10 : (repetitions == 0 ? "Unlimited" : repetitions)
def criticalIntervalValue = (criticalInterval ?: 2) as Integer
def quietHoursText = enableQuietHours ? "${new Date(timeToday(quietStartTime, location.timeZone).time).format('HH:mm', location.timeZone)} to ${new Date(timeToday(quietEndTime, location.timeZone).time).format('HH:mm', location.timeZone)}" : "Disabled"
def snoozeActive = isSnoozed()
def snoozeUntilTxt = state.snoozeUntil ? new Date(state.snoozeUntil).format('HH:mm dd-MMM', location.timeZone) : "—"
def lastCheckedTxt = state.lastCheckTs ? new Date(state.lastCheckTs as Long).format('HH:mm dd-MMM', location.timeZone) : "—"
def lowDevicesFresh = (devices ?: []).findAll { dev ->
def lvl = dev?.currentValue("battery")
if (lvl == null || !dev?.displayName) return false
def lastActivity = dev.getLastActivity()
def age = lastActivity ? (now() - lastActivity.time) / (1000*60*60) : 999
(lvl as Integer) < thresholdValue && age <= freshnessHoursValue
}
def criticalFreshCount = lowDevicesFresh.count { (it.currentValue('battery') as Integer) < criticalThresholdValue }
def lowFreshCount = lowDevicesFresh.size()
// Collect stale devices
def staleDevices = (devices ?: []).findAll { dev ->
if (!dev?.displayName) return false
def lvl = dev?.currentValue("battery")
if (lvl == null) return false
def lastActivity = dev.getLastActivity()
def age = lastActivity ? (now() - lastActivity.time) / (1000*60*60) : 999
age > freshnessHoursValue
}.collect { dev ->
def lastActivity = dev.getLastActivity()
def ageH = lastActivity ? (now() - lastActivity.time) / (1000*60*60) : 999
[dev: dev, lvl: dev.currentValue('battery') as Integer, ageH: ageH]
}.sort { -it.ageH } // Sort by age, oldest first
def lowest = (devices ?: [])
.collect { d ->
if (!d?.displayName) return null
def lvl = d?.currentValue('battery')
if (lvl == null) return null
def la = d.getLastActivity()
def ageH = la ? (now() - la.time) / (1000*60*60) : 999
[dev: d, lvl: (lvl as Integer), ageH: ageH]
}
.findAll { it != null }
?.min { it.lvl }
def lowestTxt = lowest ? {
def nameHtml = (lowest.lvl < criticalThresholdValue) ? "<b>${lowest.dev.displayName}</b>" : lowest.dev.displayName
def lvlHtml = (lowest.lvl < thresholdValue) ? "<span style='color:red'>${lowest.lvl}%</span>" : "${lowest.lvl}%"
def staleTag = lowest.ageH > freshnessHoursValue ? " (stale)" : ""
"${nameHtml}: ${lvlHtml}${staleTag}"
}() : "—"
def allOK = lowDevicesFresh.isEmpty()
paragraph "<b>Monitoring:</b> ${deviceCount} devices"
paragraph "<b>Low Battery Alert:</b> Below ${thresholdValue}%"
paragraph "<b>Critical Battery Alert:</b> Below ${criticalThresholdValue}%"
paragraph "<b>Freshness Requirement:</b> Newer than ${freshnessHoursValue} hours"
if (allOK) {
paragraph "<b>Status:</b> <span style='color:green'>All Devices Above: ${thresholdValue}% OK ✔</span>"
} else {
paragraph "<b>Status:</b> <span style='color:red'>Some devices below ${thresholdValue}% ⚠</span>"
def listHtml = lowDevicesFresh.collect { dev ->
fmtDeviceLine(dev.displayName, dev.currentValue('battery') as Integer, thresholdValue, criticalThresholdValue)
}.join("<br>")
paragraph "<div style='margin-left:8px'>${listHtml}</div>"
}
paragraph "<b>Counts:</b> Low=${lowFreshCount}, Critical=${criticalFreshCount}"
paragraph "<b>Lowest Battery:</b> ${lowestTxt}"
paragraph "<b>Last Checked:</b> ${lastCheckedTxt}"
// Display stale devices
if (staleDevices) {
paragraph "<b>⚠ Stale Devices (${staleDevices.size()}):</b> <span style='color:black'>Not reporting within ${freshnessHoursValue}h </span>"
def staleListHtml = staleDevices.collect { item ->
def ageHours = item.ageH as Integer
def daysAgo = (ageHours / 24) as Integer
def hoursAgo = (ageHours % 24) as Integer
def ageStr = daysAgo > 0 ? "${daysAgo}d ${hoursAgo}h" : "${hoursAgo}h"
"<b> ⚠ ${item.dev.displayName}: ${item.lvl}% </b> <span style='color:red'>(${ageStr} ago)</span>"
}.join("<br>")
paragraph "<div style='margin-left:8px'>${staleListHtml}</div>"
} else {
paragraph "<b>Stale Devices:</b> <span style='color:green'>None ✔</span>"
}
paragraph "<b>Next Summary:</b> ${nextSummaryTime}"
paragraph "<b>Check Interval:</b> Every ${checkIntervalValue} hours"
paragraph "<b>Check Start Time:</b> ${checkStartTimeValue}"
paragraph "<b>Critical Alert Interval:</b> Every ${criticalIntervalValue} hours"
paragraph "<b>Max Notifications:</b> ${maxNotifications} per device"
paragraph "<b>Quiet Hours:</b> ${quietHoursText}"
paragraph "<b>Phone Notifications:</b> ${phoneNotifyEnabled ? 'Enabled' : 'Disabled'}"
paragraph "<b>Logging:</b> ${enableLogging ? 'Enabled' : 'Disabled'}"
paragraph "<b>Daily Summary:</b> ${enableSummary ? 'Enabled' : 'Disabled'}"
paragraph "<b>Snooze:</b> ${snoozeActive ? "<span style='color:orange'>Active until ${snoozeUntilTxt}</span>" : "Inactive"}"
}
// section("<b>Battery Monitoring Settings</b>") {
section("<hr style='background-color:#000000; height: 2px; border: 0;'><b>🔋 Battery Monitoring Settings:</b>") {
input "devices", "capability.battery", title: "Select devices to monitor", multiple: true, required: true
input "threshold", "number", title: "Low battery threshold (%)", defaultValue: 50, required: true, range: "1..100"
paragraph "<small>Notify when battery level falls below this percentage (must be greater than critical threshold).</small>"
input "criticalThreshold", "number", title: "Critical battery threshold (%)", defaultValue: 25, required: true, range: "1..100"
paragraph "<small>Notify with higher priority when battery is critically low (must be less than low threshold).</small>"
input "criticalInterval", "number", title: "Critical alert repeat interval (hours)", defaultValue: 2, required: true, range: "1..24"
paragraph "<small>How often to repeat critical alerts.</small>"
input "freshnessHours", "number", title: "Max age of battery reading (hours)", defaultValue: 24, required: true, range: "1..168"
paragraph "<small>Ignore readings older than this to ensure accuracy.</small>"
input "interval", "number", title: "Regular check interval (hours)", defaultValue: 4, required: true, range: "1..24"
paragraph "<small>Frequency of battery checks.</small>"
input "checkStartTime", "time", title: "Start battery checks at (time of day)", required: false
paragraph "<small>Time to begin periodic checks; leave blank for immediate start.</small>"
}
// section("<b>Daily Summary Settings</b>") {
section("<hr style='background-color:#000000; height: 2px; border: 0;'><b>📅 Daily Summary Settings:</b>") {
input "enableSummary", "bool", title: "Enable daily summary?", defaultValue: false
paragraph "<small>Send a daily report of battery statuses.</small>"
input "summaryTime", "time", title: "Send summary at (time of day)", required: false
paragraph "<small>Time to send the daily summary; requires enabling summary.</small>"
}
section("<hr style='background-color:#000000; height: 2px; border: 0;'><b>🔔 Notification Settings:</b>") {
input "notifier", "capability.notification", title: "Send notifications to", multiple: true, required: true
paragraph "<small>Select devices for receiving alerts and summaries.</small>"
input "phoneNotifyEnabled", "bool", title: "Enable phone notifications?", defaultValue: false
paragraph "<small>Toggle to enable/disable phone notifications.</small>"
input "repetitions", "number", title: "Max notifications per device (0 = unlimited)", defaultValue: 0, required: true, range: "0..100"
paragraph "<small>Limit notifications to avoid spam; 0 allows unlimited.</small>"
}
// section("<b>Quiet Hours Settings</b>") {
section("<hr style='background-color:#000000; height: 2px; border: 0;'><b>🌙 Quiet Hours Settings:</b>") {
input "enableQuietHours", "bool", title: "Enable quiet hours?", defaultValue: false
paragraph "<small>Suppress notifications during specified hours.</small>"
input "quietStartTime", "time", title: "Quiet hours start time", required: false
input "quietEndTime", "time", title: "Quiet hours end time", required: false
paragraph "<small>Define the time range to mute notifications; requires enabling quiet hours.</small>"
}
// section("<b>Snooze Settings</b>") {
section("<hr style='background-color:#000000; height: 2px; border: 0;'><b>⏰ Snooze Settings:</b>") {
input "snoozeEnabled", "bool", title: "Enable snooze?", defaultValue: false
paragraph "<small>Temporarily pause all notifications.</small>"
input "snoozeDays", "enum", title: "Snooze duration", required: false, defaultValue: "1",
options: ["1":"1 day","2":"2 days","3":"3 days","4":"4 days","5":"5 days","6":"6 days","7":"7 days"]
paragraph "<small>Duration to suppress notifications; requires enabling snooze.</small>"
}
// section("<b>General Options</b>") {
section("<hr style='background-color:#000000; height: 2px; border: 0;'><b>⚙️ General Options:</b>") {
input "enableLogging", "bool", title: "Enable logging?", defaultValue: false
paragraph "<small>Enable to log detailed app activity for debugging.</small>"
}
}
}
def installed() {
logI "-= Installed"
initialize()
}
def updated() {
logI "-= Updated: clearing schedules"
unschedule()
if (!validateSettings()) {
log.error "Invalid settings detected; please check configuration"
return
}
initialize()
}
def initialize() {
// Snooze
if (snoozeEnabled) {
Integer d = (settings.snoozeDays ? settings.snoozeDays as Integer : 1)
d = Math.max(1, Math.min(7, d)) // Allow up to 7 days
if (!state.snoozeUntil || now() >= (state.snoozeUntil as Long)) {
state.snoozeUntil = now() + (d * 24L * 60L * 60L * 1000L)
logI "-= Snooze set to ${d} day(s), active until ${new Date(state.snoozeUntil).format('HH:mm dd-MMM-yyyy', location.timeZone)}"
} else {
logI "-= Snooze already active until ${new Date(state.snoozeUntil).format('HH:mm dd-MMM-yyyy', location.timeZone)}"
}
} else if (state.snoozeUntil) {
state.remove("snoozeUntil")
logI "-= Snooze canceled"
} else {
logI "-= No snooze active"
}
// Schedule checks
def validInterval = (interval ?: 4) <= 0 ? 4 : (interval ?: 4)
if (checkStartTime) {
def startTime = timeToday(checkStartTime, location.timeZone)
if (startTime.time < now()) startTime = new Date(startTime.time + 86400000)
runOnce(startTime, checkBatteriesAndSchedule)
logI "-= First battery check scheduled at ${startTime.format('HH:mm dd-MMM-yyyy', location.timeZone)}"
} else {
def t = new Date(now() + validInterval * 3600 * 1000)
runIn(validInterval * 3600, checkBatteriesAndSchedule)
logI "-= First battery check scheduled in ${validInterval}h at ${t.format('HH:mm dd-MMM-yyyy', location.timeZone)}"
}
// Summary
if (enableSummary && summaryTime) {
scheduleDailySummary()
def summaryDate = timeToday(summaryTime, location.timeZone)
if (summaryDate.time < now()) summaryDate = new Date(summaryDate.time + 86400000)
logI "-= Daily summary time: ${summaryDate.format('HH:mm dd-MMM-yyyy', location.timeZone)}"
}
// Snapshot of settings
logI "-= Settings: Low<${threshold ?: 50}%, Critical<${criticalThreshold ?: 25}% | Age<=${freshnessHours ?: 24}h | Interval=${interval ?: 4}h | QuietHours=${enableQuietHours ? 'Yes' : 'No'} | Notifiers=${notifier ? notifier.size() : 0} | Devices=${devices ? devices.size() : 0}"
}
private Boolean validateSettings() {
def valid = true
def thresholdValue = (threshold ?: 50) as Integer
def criticalThresholdValue = (criticalThreshold ?: 25) as Integer
def intervalValue = (interval ?: 4) as Integer
def criticalIntervalValue = (criticalInterval ?: 2) as Integer
def freshnessHoursValue = (freshnessHours ?: 24) as Integer
if (thresholdValue <= criticalThresholdValue) {
log.error "Validation error: Low battery threshold (${thresholdValue}%) must be greater than critical threshold (${criticalThresholdValue}%)"
valid = false
}
if (thresholdValue < 1 || thresholdValue > 100) {
log.error "Validation error: Low battery threshold (${thresholdValue}%) must be between 1 and 100"
valid = false
}
if (criticalThresholdValue < 1 || criticalThresholdValue > 100) {
log.error "Validation error: Critical battery threshold (${criticalThresholdValue}%) must be between 1 and 100"
valid = false
}
if (intervalValue < 1 || intervalValue > 24) {
log.error "Validation error: Check interval (${intervalValue}h) must be between 1 and 24 hours"
valid = false
}
if (criticalIntervalValue < 1 || criticalIntervalValue > 24) {
log.error "Validation error: Critical alert interval (${criticalIntervalValue}h) must be between 1 and 24 hours"
valid = false
}
if (freshnessHoursValue < 1 || freshnessHoursValue > 168) {
log.error "Validation error: Freshness hours (${freshnessHoursValue}h) must be between 1 and 168 hours"
valid = false
}
if (repetitions != null && (repetitions as Integer) < 0) {
log.error "Validation error: Max notifications (${repetitions}) cannot be negative"
valid = false
}
return valid
}
def checkBatteriesAndSchedule() {
if (!validateSettings()) {
log.error "Cannot schedule battery check due to invalid settings"
return
}
checkBatteries()
def validInterval = (interval ?: 4) <= 0 ? 4 : (interval ?: 4)
def next = new Date(now() + validInterval * 3600 * 1000)
runOnce(next, checkBatteriesAndSchedule)
logI "-= Next battery check scheduled at ${next.format('HH:mm dd-MMM-yyyy', location.timeZone)}"
}
private Boolean isQuietHours() {
if (!enableQuietHours || !quietStartTime || !quietEndTime) return false
def nowTime = now()
def start = timeToday(quietStartTime, location.timeZone).time
def end = timeToday(quietEndTime, location.timeZone).time
return (start <= end) ? (nowTime >= start && nowTime <= end) : (nowTime >= start || nowTime <= end)
}
private Boolean isSnoozed() {
return (state.snoozeUntil && now() < (state.snoozeUntil as Long))
}
def checkBatteries() {
def startTime = now()
state.lastCheckTs = startTime
def ts = new Date(startTime).format('HH:mm dd-MMM-yyyy', location.timeZone)
def totalDevices = (devices ?: []).size()
logI "-= Battery check started for ${totalDevices} devices at ${ts}"
def processed = 0
def skippedStale = 0
def skippedNoReading = 0
def lowCount = 0
def criticalCount = 0
(devices ?: []).each { dev ->
try {
processed++
if (!dev?.displayName) {
skippedNoReading++
logI "-= Device with ID ${dev?.id ?: 'unknown'} has no display name — skipping"
return
}
def level = dev?.currentValue("battery")
if (level == null) {
skippedNoReading++
logI "-= ${dev.displayName} (ID: ${dev.id}) Has Not Checked In — skipping"
return
}
def lastActivity = dev.getLastActivity()
def hoursSinceActivity = lastActivity ? (now() - lastActivity.time) / (1000 * 60 * 60) : 999
if (hoursSinceActivity > (freshnessHours ?: 24)) {
skippedStale++
logI "-= ${dev.displayName} (ID: ${dev.id}) battery Not Rporting (${Math.round(hoursSinceActivity)}h) — skipping"
return
}
def lvl = level as Integer
def stateKey = "reps_${dev.id}"
def lastNotifiedKey = "lastNotified_${dev.id}"
def criticalStateKey = "critical_reps_${dev.id}"
def criticalLastNotifiedKey = "critical_lastNotified_${dev.id}"
def lastBatteryLevelKey = "lastBatteryLevel_${dev.id}"
def lastBatteryLevel = state[lastBatteryLevelKey] as Integer
def lastNotified = state[lastNotifiedKey] ? new Date(state[lastNotifiedKey] as Long) : null
def hoursSinceLastNotified = lastNotified ? (now() - lastNotified.time) / (1000 * 60 * 60) : 999
def criticalLastNotified = state[criticalLastNotifiedKey] ? new Date(state[criticalLastNotifiedKey] as Long) : null
def hoursSinceCriticalNotified = criticalLastNotified ? (now() - criticalLastNotified.time) / (1000 * 60 * 60) : 999
// Count low/critical for summary stats
if (lvl < (criticalThreshold ?: 25)) {
criticalCount++
lowCount++ // Critical is also low
} else if (lvl < (threshold ?: 50)) {
lowCount++
}
// Snooze suppresses everything
if (isSnoozed()) {
logI "-= Snoozed: Suppressed notification for ${dev.displayName} (ID: ${dev.id}) (${lvl}%)"
state[lastBatteryLevelKey] = lvl
return
}
if (lvl < (criticalThreshold ?: 25)) {
if (hoursSinceCriticalNotified >= (criticalInterval ?: 2) && (lastBatteryLevel == null || lvl != lastBatteryLevel)) {
state[criticalStateKey] = ((state[criticalStateKey] ?: 0) as Integer) + 1
if ((repetitions ?: 0) == 0 || (state[criticalStateKey] as Integer) <= (repetitions as Integer)) {
def msg = "${dev.displayName} battery CRITICALLY LOW: ${lvl}% at ${ts}"
if (phoneNotifyEnabled && notifier) notifier.each { it.deviceNotification(msg) }
state[criticalLastNotifiedKey] = (state.lastCheckTs as Long)
logI "-= ${msg} (ID: ${dev.id})"
} else {
logI "-= ${dev.displayName} (ID: ${dev.id}) reached max critical notifications (${repetitions})"
}
} else if (lastBatteryLevel != null && lvl == lastBatteryLevel) {
logI "-= ${dev.displayName} (ID: ${dev.id}) battery unchanged at ${lvl}% — no critical notification"
}
} else if (lvl < (threshold ?: 50)) {
if (hoursSinceLastNotified >= (interval ?: 4) && !isQuietHours() && (lastBatteryLevel == null || lvl != lastBatteryLevel)) {
state[stateKey] = ((state[stateKey] ?: 0) as Integer) + 1
if ((repetitions ?: 0) == 0 || (state[stateKey] as Integer) <= (repetitions as Integer)) {
def msg = "${dev.displayName} battery LOW: ${lvl}% at ${ts}"
if (phoneNotifyEnabled && notifier) notifier.each { it.deviceNotification(msg) }
state[lastNotifiedKey] = (state.lastCheckTs as Long)
logI "-= ${msg} (ID: ${dev.id})"
} else {
logI "-= ${dev.displayName} (ID: ${dev.id}) reached max notifications (${repetitions})"
}
} else if (lastBatteryLevel != null && lvl == lastBatteryLevel) {
logI "-= ${dev.displayName} (ID: ${dev.id}) battery unchanged at ${lvl}% — no notification"
} else if (isQuietHours()) {
logI "-= Suppressed ${dev.displayName} (ID: ${dev.id}) due to quiet hours"
}
} else {
if (state[stateKey] || state[criticalStateKey]) {
state.remove(stateKey)
state.remove(lastNotifiedKey)
state.remove(criticalStateKey)
state.remove(criticalLastNotifiedKey)
logI "-= ${dev.displayName} (ID: ${dev.id}) battery back to normal (${lvl}%) — counters reset"
}
}
state[lastBatteryLevelKey] = lvl
} catch (e) {
log.error "Battery check error for device ${dev?.displayName ?: 'Unknown'} (ID: ${dev?.id ?: 'unknown'}): ${e}"
}
}
def endTime = now()
def duration = ((endTime - startTime) / 1000.0)
def processedFresh = processed - skippedStale - skippedNoReading
logI "-= Battery check completed: ${totalDevices} total, ${processedFresh} processed, ${skippedStale} stale, ${skippedNoReading} no-reading, ${lowCount} low, ${criticalCount} critical (${duration}s)"
}
def sendSummary() {
if (!validateSettings()) {
log.error "Cannot send summary due to invalid settings"
return
}
def ts = new Date().format('HH:mm dd-MMM-yyyy', location.timeZone)
logI "-= Generating daily battery summary at ${ts}"
if (isSnoozed()) {
logI "-= Snoozed: Suppressed daily summary"
if (enableSummary && summaryTime) scheduleDailySummary()
return
}
def lowDevices = (devices ?: []).findAll { dev ->
if (!dev?.displayName) return false
def lvl = dev?.currentValue("battery")
if (lvl == null) return false
def la = dev.getLastActivity()
def age = la ? (now() - la.time) / (1000 * 60 * 60) : 999
(lvl as Integer) < (threshold ?: 50) && age <= (freshnessHours ?: 24)
}
// Collect stale devices for summary
def staleDevices = (devices ?: []).findAll { dev ->
if (!dev?.displayName) return false
def lvl = dev?.currentValue("battery")
if (lvl == null) return false
def lastActivity = dev.getLastActivity()
def age = lastActivity ? (now() - lastActivity.time) / (1000*60*60) : 999
age > (freshnessHours ?: 24)
}.collect { dev ->
def lastActivity = dev.getLastActivity()
def ageH = lastActivity ? (now() - lastActivity.time) / (1000*60*60) : 999
[dev: dev, lvl: dev.currentValue('battery') as Integer, ageH: ageH]
}.sort { -it.ageH } // Sort by age, oldest first
def lowestForSummary = (devices ?: [])
.collect { d ->
if (!d?.displayName) return null
def lvl = d?.currentValue('battery')
if (lvl == null) return null
def la = d.getLastActivity()
def ageH = la ? (now() - la.time) / (1000 * 60 * 60) : 999
[dev: d, lvl: (lvl as Integer), ageH: ageH]
}
.findAll { it != null }
?.min { it.lvl }
def lowestInfo = lowestForSummary ?
"${lowestForSummary.dev.displayName}: ${lowestForSummary.lvl}%" :
"No devices found"
def totalDevices = (devices ?: []).size()
def processedFresh = 0
def skippedStale = 0
def skippedNoReading = 0
def lowCount = 0
def criticalCount = 0
(devices ?: []).each { dev ->
if (!dev?.displayName) {
skippedNoReading++
return
}
def lvl = dev?.currentValue("battery")
if (lvl == null) {
skippedNoReading++
return
}
def la = dev.getLastActivity()
def age = la ? (now() - la.time) / (1000 * 60 * 60) : 999
if (age > (freshnessHours ?: 24)) {
skippedStale++
return
}
processedFresh++
def level = lvl as Integer
if (level < (criticalThreshold ?: 25)) {
criticalCount++
lowCount++
} else if (level < (threshold ?: 50)) {
lowCount++
}
}
if (phoneNotifyEnabled && !isQuietHours() && notifier) {
def msg = "-= Daily Battery Summary =-\n\n"
msg += "Lowest Battery: ${lowestInfo}\n"
msg += "Last Check Stats: ${totalDevices} total\n"
msg += "Stats: ${processedFresh} processed, ${skippedStale} stale, ${skippedNoReading} no-reading, ${lowCount} low, ${criticalCount} critical\n\n"
if (staleDevices) {
msg += "Stale Devices (${staleDevices.size()}):\n"
msg += staleDevices.collect { item ->
def ageHours = item.ageH as Integer
def daysAgo = (ageHours / 24) as Integer
def hoursAgo = (ageHours % 24) as Integer
def ageStr = daysAgo > 0 ? "${daysAgo}d ${hoursAgo}h" : "${hoursAgo}h"
"${item.dev.displayName}: ${item.lvl}% (${ageStr} ago)"
}.join("\n")
msg += "\n\n"
}
if (lowDevices) {
msg += "Devices Below ${threshold ?: 50}%:\n" + lowDevices.collect {
def lvl = it.currentValue('battery') as Integer
"${it.displayName}: ${lvl}%${lvl < (criticalThreshold ?: 25) ? ' (CRITICAL)' : ''}"
}.join("\n")
} else {
msg += "All Devices Above: ${threshold ?: 50}% threshold"
}
notifier.each { it.deviceNotification(msg) }
logI "-= ${msg.replace('\n', ' | ')}"
} else if (isQuietHours()) {
logI "-= Suppressed daily summary due to quiet hours"
} else if (!phoneNotifyEnabled || !notifier) {
logI "-= No notification devices configured or notifications disabled"
}
if (enableSummary && summaryTime) scheduleDailySummary()
}
def scheduleDailySummary() {
if (!summaryTime) return
def summaryDate = timeToday(summaryTime, location.timeZone)
if (summaryDate.time < now()) summaryDate = new Date(summaryDate.time + 86400000)
runOnce(summaryDate, sendSummary)
logI "-= Daily summary scheduled at ${summaryDate.format('HH:mm dd-MMM-yyyy', location.timeZone)}"
}
def appButtonHandler(btn) {
switch(btn) {
case "checkNow":
logI "-= Manual: Check Now pressed"
checkBatteries()
break
case "sendSummaryNow":
logI "-= Manual: Send Summary Now pressed"
sendSummary()
break
case "resetCounters":
def keysToRemove = state.findAll { key, _ ->
key.startsWith("reps_") ||
key.startsWith("lastNotified_") ||
key.startsWith("critical_reps_") ||
key.startsWith("critical_lastNotified_") ||
key.startsWith("lastBatteryLevel_")
}.keySet()
keysToRemove.each { state.remove(it) }
logI "-= All counters and timers reset manually"
break
case "cancelSnooze":
logI "-= Manual: Cancel Snooze pressed"
state.remove("snoozeUntil")
app.removeSetting("snoozeEnabled")
logI "-= Snooze canceled and toggle reset"
break
}
}
// === Helpers ===
private String fmtDeviceLine(String name, Integer lvl, Integer threshold, Integer criticalThreshold) {
def nameHtml = (lvl < criticalThreshold) ? "<b>${name}</b>" : name
def lvlHtml = (lvl < threshold) ? "<span style='color:red'>${lvl}%</span>" : "${lvl}%"
def critTag = (lvl < criticalThreshold) ? " <span style='color:red'>(CRITICAL)</span>" : ""
return "• ${nameHtml}: ${lvlHtml}${critTag}"
}
private void logI(String msg) {
if (enableLogging) log.info(msg)
}

