[RELEASE] Battery Level Reminder - Phone Notifier - V8

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.

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

1 Like

@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.

1 Like

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.