[RELEASE] Battery Level Reminder - Phone Notifier - V10

Battery Level Reminder How this app works:

Made By: WW & Claude Ai

• Full Status Panel added.
• 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.

:hammer_and_wrench: Installing a Custom App (User-Created Code)

  1. Log into your Hubitat admin interface.
  2. Go to the "Apps Code" section under the "For Developers" menu
  3. (enable "Show advanced/developer options" in Settings if not visible).
  4. Click New App.
  5. Paste in the code you copied from below code.
  6. Click Save.
  7. Go to the Apps page → Add User App → select your new app.
  8. Configure it with your devices/settings.
definition(
    name: "Battery Level Reminder V10",
    namespace: "BLR V10 - 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>📱 Device Battery Levels:</b>") {
            if (devices && devices.size() > 0) {
                def thresholdValue = (threshold ?: 50) as Integer
                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 <= (freshnessHours ?: 24)
                }
                def allOK = lowDevicesFresh.isEmpty()
                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 deviceBatteryHtml = devices.collect { dev ->
                    if (!dev?.displayName) return null
                    def lvl = dev?.currentValue("battery")
                    if (lvl == null) {
                        return "<b>${dev.displayName}</b>: <span style='color:orange'>No Reading</span>"
                    }
                    def lvlInt = lvl as Integer
                    def lvlColor = lvlInt < (criticalThreshold ?: 25) ? 'red' : 
                                   lvlInt < (threshold ?: 50) ? 'orange' : 'green'
                    "<b>${dev.displayName}</b>: <span style='color:${lvlColor}'>${lvlInt}%</span>"
                }.findAll { it != null }.sort().join("<br>")
                paragraph "<div style='margin-left:8px'>${deviceBatteryHtml}</div>"
            } else {
                paragraph "<i>No devices selected yet</i>"
            }
        }

        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)
}

@WarlockWeary
Thanks for your new app. It looks good. Waiting to see the report in the morning.:+1:
Immediate request for a new feature :grinning:
Since we travel quite a bit, it would be great to use a switch to turn off the reporting to the phone when the switch is set, since I won't be able to change batteries until we get home.

Sure we can add that but it would still be nice to know that when you get home you have to do it lol anyway I'll see if I can add it.

  • Phone notifications can be turned on/off now.
  • and reg and very low battery warnings.

Yeah, 9am the first morning we're back, the report will interrupt my breakfast.

V10 - Added new status update to main page for all devices it checks
• Few Other Tweaks