definition( name: "Battery Monitor 2.0", namespace: "jdthomas24", author: "Jdthomas24", description: "Advanced Hubitat battery monitoring with analytics, trends and replacement tracking. Recurring scan schedule, confidence-weighted health, EWMA smoothing.", category: "Convenience", importUrl: "https://raw.githubusercontent.com/jdthomas24/Hubitat-Apps-Drivers/refs/heads/main/Battery%20Monitor%202.0/Raw%20Code/BatteryMonitor2.0.groovy", iconUrl: "https://raw.githubusercontent.com/jdthomas24/Hubitat-Apps-Drivers/refs/heads/main/Tests%20-%20Groovy%20RAW/Battery%20Monitor%202.0%20BETA%20Tests", iconX2Url: "https://raw.githubusercontent.com/jdthomas24/Hubitat-Apps-Drivers/refs/heads/main/Battery%20Monitor%202.0/Raw%20Code/BatteryMonitor2.0.groovy", version: "2.5.29", doNotFocus: true, oauth: true ) // ============================================================ // ===================== OAUTH MAPPINGS ====================== // ============================================================ mappings { path("/dashboard") { action: [GET: "serveDashboardPage"] } path("/refresh") { action: [GET: "forceRefreshEndpoint"] } } // ============================================================ // ===================== LIFECYCLE =========================== // ============================================================ def installed() { if (debugMode) log.debug "Installed - initializing app" applyCustomLabel() initialize() } def updated() { if (debugMode) log.debug "Updated - re-initializing app" applyCustomLabel() unschedule() unsubscribe() initialize() runIn(1800, disableDebugLogging) def devList = autoDevices ?: [] def currentIds = devList.collect { it.id as String } state.history?.keySet()?.findAll { !currentIds.contains(it) }?.each { removedId -> state.history.remove(removedId) state.trend?.remove(removedId) if (debugMode) log.debug "Cleaned up removed device: ${removedId}" } if (state.replacements) { def before = state.replacements.size() state.replacements = state.replacements.findAll { r -> r.deviceId ? currentIds.contains(r.deviceId) : true } def pruned = before - state.replacements.size() if (pruned > 0 && debugMode) log.debug "Purged ${pruned} orphaned replacement history entr${pruned == 1 ? 'y' : 'ies'}" } if (state.pendingReplacement) { def pendingBefore = state.pendingReplacement.size() state.pendingReplacement = state.pendingReplacement.findAll { id, _ -> currentIds.contains(id) } def pendingPruned = pendingBefore - state.pendingReplacement.size() if (pendingPruned > 0 && debugMode) log.debug "Pruned ${pendingPruned} orphaned pending-replacement entr${pendingPruned == 1 ? 'y' : 'ies'}" } def migrationDirty = false def migratedHistory = [:] state.history?.each { id, data -> if (data && !data.firstSeenDate) { def newData = new HashMap(data) newData.firstSeenDate = newData.replacedTime ?: newData.lastDate ?: now() migratedHistory[id] = newData migrationDirty = true if (debugMode) log.debug "Migrated firstSeenDate for device ${id}: ${new Date(newData.firstSeenDate as long)}" } else { migratedHistory[id] = data } } if (migrationDirty) state.history = migratedHistory def didMigrate = false state.replacements?.each { r -> if (!r.deviceId) { def match = autoDevices?.find { it.displayName == r.device } if (match) { r.deviceId = match.id didMigrate = true if (debugMode) log.debug "Migrated replacement entry deviceId for ${r.device}: ${r.deviceId}" } } } if (didMigrate) state.replacements = state.replacements def devList2 = autoDevices ?: [] devList2.each { device -> def legacyInfo = settings["battInfo_${device.id}"] if (legacyInfo && legacyInfo != "" && !legacyInfo.startsWith("_sep") && !settings["battType_${device.id}"]) { def parts = legacyInfo.tokenize(" x") if (parts.size() >= 2) { def migratedType = parts[0..-2].join(" ") def migratedCount = parts[-1] app.updateSetting("battType_${device.id}", [value: migratedType, type: "enum"]) app.updateSetting("battCount_${device.id}", [value: migratedCount.toInteger(), type: "number"]) } else { app.updateSetting("battType_${device.id}", [value: legacyInfo, type: "enum"]) app.updateSetting("battCount_${device.id}", [value: 1, type: "number"]) } app.removeSetting("battInfo_${device.id}") if (debugMode) log.debug "Migrated battery catalog entry for ${device.displayName}: ${legacyInfo}" } } } def disableDebugLogging() { log.info "Battery Monitor: auto-disabling debug logging after 30 minutes" app.updateSetting("debugMode", [value: false, type: "bool"]) } def initialize() { if (debugMode) log.debug "Initialization complete" if (state.replacements == null) state.replacements = [] if (state.history == null) state.history = [:] if (state.trend == null) state.trend = [:] if (state.notifSnoozedUntil == null) state.notifSnoozedUntil = 0 if (state.ignoredDeviceIds == null) state.ignoredDeviceIds = [] if (state.pendingReplacement == null) state.pendingReplacement = [:] if (!state.accessToken) { try { createAccessToken() } catch (e) { log.error "Battery Monitor: OAuth is not enabled. Please enable OAuth in the App Code screen." } } scheduleReportFrequency() scheduleScanInterval() def devList = autoDevices ?: [] if (devList) { subscribe(devList, "battery", batteryHandler) } } def applyCustomLabel() { if (settings?.customAppName) { if (app.label != settings?.customAppName) { app.updateLabel(settings.customAppName) if (debugMode) log.debug "App label updated to: ${settings.customAppName}" } } } // ============================================================ // ===================== IGNORED DEVICE HELPER =============== // ============================================================ def isIgnored(device) { if (!device) return false def ignoredIds = (settings?.ignoredDevices?.collect { it as String }) ?: [] return ignoredIds.contains(device.id as String) } // ============================================================ // ===================== PREFERENCES ========================= // ============================================================ preferences { page(name: "mainPage") page(name: "summaryPage") page(name: "trendsPage") page(name: "historyPage") page(name: "deleteHistoryPage") page(name: "deleteHistoryConfirmPage") page(name: "infoPage") page(name: "forceScanPage") page(name: "sendNotificationPage") page(name: "deviceManagePage") page(name: "deviceActionsPage") page(name: "bulkActionsPage") page(name: "bulkActionsResultPage") page(name: "ignoredDevicesPage") page(name: "detectionSettingsPage") page(name: "batteryTypesPage") } // ============================================================ // ===================== MAIN PAGE =========================== // ============================================================ def mainPage() { applyCustomLabel() dynamicPage(name: "mainPage", title: "", install: true, uninstall: true) { def currentLabel = app.label ?: "Battery Monitor 2.0" def appNameTitle = "App Display Name β€” ${currentLabel}" section(appNameTitle, hideable: true, hidden: true) { paragraph "Enter a name to rename this app in your Hubitat app list." input "customAppName", "text", title: "Custom App Name", description: "Rename how this app appears in your Hubitat app list", required: false } def portalEnabled = state.accessToken != null def portalStatus = portalEnabled ? "Enabled" : "Not Enabled" def portalSectionTitle = "🌐 Battery Web Portal β€” ${portalStatus}" section(portalSectionTitle, hideable: true, hidden: portalEnabled) { if (portalEnabled) { def cloudUrl = getFullApiServerUrl() def localUrl = getFullLocalApiServerUrl() paragraph "
" + "Cloud URL (use anywhere):
" + "${cloudUrl}/dashboard?access_token=${state.accessToken}

" + "Local URL (use at home):
" + "${localUrl}/dashboard?access_token=${state.accessToken}

" + "
" } else { def hubIp = location?.hub?.localIP ?: "" paragraph "
" + "OAuth is not yet enabled. To activate the web portal:

" + "1. Go to Apps Code in the Hubitat menu" + (hubIp ? " β€” tap here to open Apps Code" : "") + "
" + "2. Find Battery Monitor 2.0 in the list and open it
" + "3. Click OAuth in the top-right of the code editor
" + "4. Click Enable OAuth in App β†’ Update
" + "5. Return here and tap Done to save β€” the portal URLs will appear above." + "
" } } def devicesSelected = (autoDevices?.size() ?: 0) > 0 def devSectionTitle = devicesSelected ? "Selected Monitored Devices β€” ${autoDevices.size()} selected" : "Selected Monitored Devices" section(devSectionTitle, hideable: true, hidden: devicesSelected) { paragraph "⚠ Important: The app automatically detects all devices reporting battery levels. " + "Select the devices you want to monitor from the list below. Only selected devices will be tracked for trends, battery health, and notifications." paragraph "Note for mobile users: If your device names are long, they may extend past the screen in the selection list. This is a UI limitation on smaller screens. You can still select devices as usual." paragraph "IMPORTANT: After selecting devices, you MUST click 'Done' to exit the app BEFORE viewing the battery report. Skipping this step may cause an error." input "autoDevices", "capability.battery", title: "Select battery devices to monitor", multiple: true, required: false } if (devicesSelected) { def devList = autoDevices ?: [] if (devList) { if (!state.history) state.history = [:] if (!state.trend) state.trend = [:] devList.each { device -> app.updateSetting("deviceName_${device.id}", [value: device.displayName, type: "string"]) if (!state.history[device.id]) { def currentLevel = device.currentValue("battery") state.history[device.id] = [ lastLevel: currentLevel != null ? currentLevel.toInteger() : 100, lastDate: now(), lastScanDate: now(), firstSeenDate: now(), drain: 0.3, samples: [], justReplaced: false ] state.trend[device.id] = "Stable" } } } } section("") { input "scanInterval", "enum", title: "Battery Scan Interval", description: "How often battery levels are read. More frequent = faster health ratings. Devices also update on their own battery events.", options: ["1": "Hourly", "3": "Every 3 Hours", "6": "Every 6 Hours"], defaultValue: "3", submitOnChange: true } def snoozed = state.notifSnoozedUntil && state.notifSnoozedUntil >= now() def snoozeHoursLeft = snoozed ? Math.ceil((state.notifSnoozedUntil - now()) / 3600000).toInteger() : 0 def snoozeSectionTitle = snoozed ? "Notification Snooze β€” 😴 ${snoozeHoursLeft}h remaining" : "Notification Snooze β€” Off" section(snoozeSectionTitle, hideable: true, hidden: !snoozed) { paragraph "Silence all Battery Monitor notifications for a set duration. Useful when traveling or away from home." if (snoozed) { paragraph "😴 Notifications snoozed β€” ${snoozeHoursLeft}h remaining" input "snoozeConfirmClear", "bool", title: "βœ… Clear snooze β€” resume notifications now", defaultValue: false, submitOnChange: true if (settings?.snoozeConfirmClear == true) { state.notifSnoozedUntil = 0 app.updateSetting("snoozeConfirmClear", [value: false, type: "bool"]) paragraph "βœ… Snooze cleared β€” notifications resumed." } } else { input "snoozeDurationDays", "number", title: "Snooze duration (days):", defaultValue: 7, required: true input "snoozeConfirm", "bool", title: "😴 Confirm β€” snooze notifications", defaultValue: false, submitOnChange: true if (settings?.snoozeConfirm == true) { def days = (settings?.snoozeDurationDays ?: 7).toInteger() state.notifSnoozedUntil = now() + (days * 86400000) app.updateSetting("snoozeConfirm", [value: false, type: "bool"]) paragraph "😴 Notifications snoozed for ${days} day(s)." } } } def notifOn = settings?.enablePush != false def notificationSettings = (notificationSettings != false) def notifSectionTitle = "Notifications β€” ${notifOn ? "On" : "Off"}" section(notifSectionTitle, hideable: true, hidden: notificationSettings) { paragraph "ℹ️ Enable the toggle below to reveal notification settings including frequency, timing, device targets, and which battery groups to include in reports." input "enablePush", "bool", title: "Enable notifications", defaultValue: true, submitOnChange: true if (settings?.enablePush != false) { input "reportFrequency", "enum", title: "Notification Frequency:", options: ["daily": "Daily", "every2": "Every 2 Days", "every3": "Every 3 Days", "weekly": "Weekly"], defaultValue: "daily" input "summaryTime", "time", title: "Notification Time:", required: false input "notifyDevices", "capability.notification", title: "Notification devices", multiple: true, required: false input "enablePushover", "bool", title: "βš™οΈ Enable Pushover Markup", defaultValue: false input "pushoverDevices", "capability.notification", title: "Pushover notification devices (receives Pushover-formatted message)", multiple: true, required: false input "pushoverPrefix", "text", title: "Pushover tags (Only used if Enable Pushover Markup is toggled ON)", description: "Pushover-specific additions to the Battery Monitor notifications, e.g. [H][TITLE=Battery Report][HTML][SELFDESTRUCT=43200]", required: false paragraph "Report Sections (choose which battery groups to include in notifications):" input "notifyPoor", "bool", title: "πŸ”΄ Include Poor (≀25%)", defaultValue: true input "notifyFair", "bool", title: "🟠 Include Fair (26–70%)", defaultValue: true input "notifyGood", "bool", title: "🟒 Include Good (71–99%)", defaultValue: false input "notifyExcellent", "bool", title: "🟒 Include Excellent (100%)", defaultValue: false input "notifyHighDrain", "bool", title: "⚠️ Include Health (Fair, Poor, & High Drain Only)", defaultValue: true input "notifyStale", "bool", title: "⚠️ Include Stale Devices", defaultValue: true input "staleThresholdHours", "number", title: "Mark device as stale if no activity for X hours", defaultValue: 24 input "suppressEmptyReport", "bool", title: "πŸ”• Don't send notification if nothing to report (Skips Notification entirely when all enabled toggles are Empty)", defaultValue: false input "notifyIncludeAppLink", "bool", title: "πŸ”— Include link to Battery Monitor app (Local Only)", defaultValue: false paragraph "Send notification now:" href(name: "toSendNotification", page: "sendNotificationPage", title: "πŸ“€ Send Notification Now") } } section("Reports:") { href(name: "toSummary", page: "summaryPage", title: "Battery Summary", description: "Battery levels and health ratings") href(name: "toTrends", page: "trendsPage", title: "Battery Trends", description: "Drain rates and trend history") href(name: "toHistory", page: "historyPage", title: "Battery Replacement History", description: "Auto and manual replacement log") href(name: "toDevManage", page: "deviceManagePage", title: "πŸ”‹ Device Battery Management", description: "Assign battery types, log replacements, reset drain history, view history") } section("Help & Support") { href(name: "toInfo", page: "infoPage", title: "πŸ“– App Guide & Reference", description: "Colors, drain rates, trends, confidence, and replacement detection explained") href url: "https://community.hubitat.com/t/release-battery-monitor-2-0/162329/288", style: "external", title: "πŸ’¬ Hubitat Community Thread", description: "Questions, feedback, bug reports, and release notes" href url: "https://paypal.me/jdthomas24?locale.x=en_US&country.x=US", style: "external", title: "β˜• Buy Me a Coffee", description: "Enjoying the app? Any amount is appreciated β€” thank you!" } section("Diagnostics") { input "debugMode", "bool", title: "Debug Logging (auto-disables after 30 min)", defaultValue: false, submitOnChange: true paragraph "Battery Monitor v2.5.29" } } } // ============================================================ // ===================== REPORT SCHEDULING ================== // ============================================================ def scheduleReportFrequency() { unschedule("reportScheduler") if (!summaryTime) return schedule(summaryTime, reportScheduler) } def scheduleScanInterval() { unschedule("scanAllDevices") def interval = (settings?.scanInterval ?: "3").toInteger() def cronExpr = "" switch (interval) { case 1: cronExpr = "0 0 * * * ?"; break case 3: cronExpr = "0 0 */3 * * ?"; break case 6: cronExpr = "0 0 */6 * * ?"; break default: cronExpr = "0 0 */3 * * ?"; break } schedule(cronExpr, scanAllDevices) if (debugMode) log.debug "Battery scan scheduled every ${interval}h (cron: ${cronExpr})" } def scanAllDevices() { def devList = (autoDevices ?: []).findAll { !isIgnored(it) } if (!devList) return if (debugMode) log.debug "Running scheduled battery scan for ${devList.size()} device(s)" log.info "Battery Monitor: scan started β€” ${devList.size()} device(s)" confirmPendingReplacements() def poor = [] def stale = [] devList.each { device -> try { app.updateSetting("deviceName_${device.id}", [value: device.displayName, type: "string"]) def level = device.currentValue("battery")?.toInteger() if (level != null) { updateBattery(device, level) if (debugMode) log.debug "Scanned ${device.displayName}: ${level}%" if (level <= 25 && !isBatteryDead(device)) poor << "${device.displayName} (${level}%)" if (isStale(device)) stale << device.displayName } } catch (e) { log.warn "Scan failed for ${device.displayName}: ${e.message}" } } def ts = new Date().format("MM/dd h:mm a", location.timeZone) def msg = "SCAN: ${devList.size()} device(s) scanned at ${ts}." if (poor.size() > 0) msg += " Low battery: ${poor.join(', ')}." if (stale.size() > 0) msg += " Stale: ${stale.join(', ')}." log.info "Battery Monitor: scan complete β€” ${devList.size()} device(s) processed" } def reportScheduler() { switch (reportFrequency) { case "daily": scheduledSummary(); break case "every2": if (shouldRunEveryXDays(2)) scheduledSummary(); break case "every3": if (shouldRunEveryXDays(3)) scheduledSummary(); break case "weekly": if (shouldRunWeekly()) scheduledSummary(); break } } def shouldRunEveryXDays(daysInterval) { def today = new Date().clearTime() def lastRun = state.lastReportRun ? new Date(state.lastReportRun).clearTime() : null if (!lastRun) { state.lastReportRun = now(); return true } def diff = (today.time - lastRun.time) / (1000 * 60 * 60 * 24) if (diff >= daysInterval) { state.lastReportRun = now(); return true } return false } def shouldRunWeekly() { def today = new Date() def lastRun = state.lastReportRun ? new Date(state.lastReportRun) : null if (!lastRun) { state.lastReportRun = now(); return true } if (today.format("u") == "1") { def diff = (today.time - lastRun.time) / (1000 * 60 * 60 * 24) if (diff >= 7) { state.lastReportRun = now(); return true } } return false } def scheduledSummary() { if (state.notifSnoozedUntil && state.notifSnoozedUntil >= now()) { if (debugMode) log.debug "Notifications snoozed until ${new Date(state.notifSnoozedUntil)} β€” skipping summary" return } def devList = (autoDevices ?: []).findAll { it?.currentValue("battery") != null && !isIgnored(it) } if (!devList) return def categories = [ "πŸ”΄ Poor": [list: [], enabled: notifyPoor != null ? notifyPoor : true], "🟠 Fair": [list: [], enabled: notifyFair != null ? notifyFair : true], "🟒 Good": [list: [], enabled: notifyGood != null ? notifyGood : false], "🟒 Excellent": [list: [], enabled: notifyExcellent != null ? notifyExcellent : false] ] devList.each { device -> if (isBatteryDead(device)) return def lvl = device.currentValue("battery") != null ? device.currentValue("battery").toInteger() : 100 def cat = lvl >= 100 ? "🟒 Excellent" : lvl > 70 ? "🟒 Good" : lvl > 25 ? "🟠 Fair" : "πŸ”΄ Poor" categories[cat].list << [device: device, name: device.displayName.trim(), level: lvl] } categories.each { cat, data -> categories[cat].list = data.list.sort { a, b -> a.level != b.level ? a.level <=> b.level : a.name <=> b.name } } def highDrainList = devList.findAll { device -> if (isBatteryDead(device)) return false def h = health(device) (h == "Poor" || h == "Fair") && getDrain(device) > 1.5 }.collect { device -> def lvl = device.currentValue("battery") != null ? device.currentValue("battery").toInteger() : 100 [name: device.displayName.trim(), level: lvl, health: health(device), drain: displayDrain(device)] }.sort { a, b -> a.level != b.level ? a.level <=> b.level : a.name <=> b.name } def deadBatteryList = devList.findAll { isBatteryDead(it) }.collect { device -> [name: device.displayName.trim()] }.sort { a, b -> a.name <=> b.name } def usePushover = (settings?.enablePushover == true && settings?.pushoverPrefix?.trim()) def prefix = "" def postfix = "" if (usePushover) { def tags = settings.pushoverPrefix.trim() def priorityMatch = tags =~ /^(\[[EHLNS]\])(.*)/ if (priorityMatch) { prefix = priorityMatch[0][1] postfix = priorityMatch[0][2].trim() } else { postfix = tags } } def timestamp = new Date().format("MM/dd HH:mm", location.timeZone) def body = "${prefix}πŸ”‹ Battery Summary β€” ${timestamp}\n" def staleDevices = devList.findAll { isStale(it) }.collect { def last = getLastActivityTime(it) def inactiveStr = last ? formatInactive(last) : "unknown" [device: it, name: it.displayName, inactiveStr: inactiveStr] } categories.each { cat, data -> if (data.enabled) { if (data.list) { body += "\n${cat}:\n" data.list.each { dev -> if (cat == "πŸ”΄ Poor") { def info = getCatalogBatteryInfo(dev.device) def infoStr = info ? " (${info})" : "" body += "β€’ ${dev.level}% ${dev.name}${infoStr}\n" } else { body += "β€’ ${dev.level}% ${dev.name}\n" } } } else { body += "\n${cat}: None\n" } } } if (notifyHighDrain != null ? notifyHighDrain : true) { if (highDrainList) { body += "\n⚠️ High Drain (Fair/Poor):\n" highDrainList.each { dev -> body += "β€’ ${dev.health} (${dev.drain}%) ${dev.name} (${dev.level}%)\n" } } else { body += "\n⚠️ High Drain (Fair/Poor): None\n" } } if (notifyStale != null ? notifyStale : true) { if (staleDevices) { body += "\n⚠️ Stale Devices:\n" staleDevices.each { d -> def info = getCatalogBatteryInfo(d.device) def infoStr = info ? " (${info})" : "" body += "β€’ ${d.name}${infoStr} β€” inactive ${d.inactiveStr}\n" } } else { body += "\n⚠️ Stale Devices: None\n" } } if (deadBatteryList) { body += "\nπŸͺ« Dead Batteries:\n" deadBatteryList.each { dev -> def foundDev = dev.deviceId ? devList.find { it.id == dev.deviceId } : devList.find { it.displayName.trim() == dev.name } def info = getCatalogBatteryInfo(foundDev) def infoStr = info ? " (${info})" : "" body += "β€’ ${dev.name}${infoStr}\n" } } if (suppressEmptyReport != null ? suppressEmptyReport : false) { def hasContent = categories.any { cat, data -> data.enabled && data.list } || ((notifyHighDrain != null ? notifyHighDrain : true) && highDrainList) || ((notifyStale != null ? notifyStale : true) && staleDevices) || deadBatteryList if (!hasContent) return } def pushoverBody = body def plainBody = body if (notifyIncludeAppLink != null ? notifyIncludeAppLink : false) { def hubIp = location.hub.localIP def htmlLink = "\nπŸ”— Battery Monitor" def plainLink = "\nπŸ”— Battery Monitor: http://${hubIp}/installedapp/configure/${app.id}/mainPage" pushoverBody += htmlLink plainBody += plainLink } if (postfix) pushoverBody += "${postfix}\n" if (settings?.enablePush) sendPush(pushoverBody) if (settings?.pushoverDevices) settings.pushoverDevices.each { it.deviceNotification(pushoverBody) } if (settings?.notifyDevices) notifyDevices.each { it.deviceNotification(plainBody) } def poorCount = categories["πŸ”΄ Poor"]?.list?.size() ?: 0 def staleCount = staleDevices?.size() ?: 0 def deadCount = deadBatteryList?.size() ?: 0 } // ============================================================ // ===================== BATTERY HANDLER ===================== // ============================================================ def batteryHandler(evt) { def device = evt.device if (isIgnored(device)) return def level = null try { level = evt.value ? (int) Double.parseDouble(evt.value) : null } catch (e) { log.warn "batteryHandler: Could not parse battery level '${evt.value}' for ${device?.displayName}: ${e.message}" } if (device && level != null) { updateBattery(device, level) } } def updateBattery(device, level) { def data = state.history[device.id] if (!data) { state.history[device.id] = [ lastLevel: level != null ? level : 100, lastDate: now(), lastScanDate: now(), firstSeenDate: now(), drain: 0.3, samples: [], justReplaced: false, zeroCount: 0 ] state.trend[device.id] = "Stable" state.history = state.history data = state.history[device.id] } if (level <= 1) { data.zeroCount = (data.zeroCount ?: 0) + 1 } else { data.zeroCount = 0 } if (isBatteryDead(device)) { data.lastLevel = level data.lastScanDate = now() state.history[device.id] = data state.history = state.history if (debugMode) log.debug "${device.displayName}: battery confirmed dead (${data.zeroCount} consecutive 0% readings)" return } if (level <= 1 && (data.zeroCount ?: 0) < 3) { data.justReplaced = false data.replacedTime = null data.drain = 1.0 data.samples = [] state.trend[device.id] = "Heavy Drain" } detectReplacement(device, level, data.lastLevel) def replacedAt = data.replacedTime ?: now() if (data.justReplaced && level < 95) { data.justReplaced = false } else if (data.justReplaced && (now() - safeTime(replacedAt)) > 1000 * 60 * 60 * 24) { data.justReplaced = false } def days = (now() - safeTime(data.lastDate)) / (1000 * 60 * 60 * 24) def hours = days * 24 if (days > 0 && hours >= 1.0 && !data.justReplaced) { def lastLevel = data.lastLevel != null ? data.lastLevel : 100 def rawDrain = (lastLevel - level) / days def clampedDrain = Math.max(0.0, Math.min(rawDrain, 5.0)) def validSample = (rawDrain > 0) || (rawDrain == 0 && hours >= 24) if (validSample) { def isOutlier = false if (data.samples && data.samples.size() >= 3) { def rollingAvg = data.samples.sum() / data.samples.size() if (rollingAvg > 0 && clampedDrain > rollingAvg * 4) { isOutlier = true if (debugMode) log.debug "${device.displayName}: outlier sample rejected β€” clampedDrain=${clampedDrain}, rollingAvg=${rollingAvg}" } } if (!isOutlier) { def alpha = 0.3 def prevSmooth = (data.samples && data.samples.size() > 0) ? data.samples[-1] : clampedDrain def smoothed = alpha * clampedDrain + (1 - alpha) * prevSmooth data.samples << smoothed if (data.samples.size() > 10) data.samples.remove(0) data.lastDate = now() } if (data.samples && data.samples.size() > 0) { def avg = data.samples.sum() / data.samples.size() data.drain = Math.min(avg, 3.0) updateTrend(device, data.drain) } } } data.lastLevel = level data.lastScanDate = now() state.history[device.id] = data state.history = state.history } // ============================================================ // ===================== DEAD BATTERY DETECTION ============== // ============================================================ def isBatteryDead(device) { def data = state.history?.get(device.id) if (!data) return false def level = device.currentValue("battery") def zeroCount = data.zeroCount ?: 0 return (level != null && level.toInteger() <= 1 && zeroCount >= 3) } // ============================================================ // ===================== DETECT REPLACEMENT ================== // ============================================================ def detectReplacement(device, newLevel, oldLevel) { newLevel = newLevel != null ? newLevel : 100 oldLevel = oldLevel != null ? oldLevel : (state.history[device.id]?.lastLevel != null ? state.history[device.id].lastLevel : 0) if (!state.history[device.id]) { state.history[device.id] = [ lastLevel: oldLevel, lastDate: now(), lastScanDate: now(), drain: 0.3, samples: [], justReplaced: false, zeroCount: 0 ] state.trend[device.id] = "Stable" } def data = state.history[device.id] def sampleCount = data?.samples?.size() ?: 0 if (sampleCount < 3) { if (debugMode) log.debug "${device.displayName}: replacement gate β€” only ${sampleCount}/3 prior samples, skipping" return } def firstSeen = data?.firstSeenDate ?: data?.lastDate ?: now() def ageDays = (now() - (firstSeen as Long)) / (1000 * 60 * 60 * 24) if (ageDays < 3) { if (debugMode) log.debug "${device.displayName}: replacement gate β€” device only ${ageDays.toInteger()}d old (min 3d), skipping" return } def lastLogged = data?.lastReplacementLogged if (lastLogged) { def hoursSinceLast = (now() - (lastLogged as Long)) / (1000 * 60 * 60) if (hoursSinceLast < 12) { if (debugMode) log.debug "${device.displayName}: replacement gate β€” last replacement ${hoursSinceLast.toInteger()}h ago (cooldown 12h), skipping" return } } // v2.5.29: Simplified detection β€” any upward jump of minJump% or more qualifies. // Batteries only drain naturally; any significant upward jump means a new battery was installed. def minJump = (settings?.detectionMinJump ?: 30).toInteger() def largeJump = newLevel - oldLevel def qualifies = (largeJump >= minJump) if (!qualifies) { if (state.pendingReplacement?.containsKey(device.id)) { if (debugMode) log.debug "${device.displayName}: pending replacement cleared β€” jump ${largeJump}% < threshold ${minJump}% (${oldLevel}% β†’ ${newLevel}%)" state.pendingReplacement.remove(device.id) state.pendingReplacement = state.pendingReplacement } return } def requireConfirm = true def confirmWindowHrs = 48 if (!state.pendingReplacement) state.pendingReplacement = [:] def pending = state.pendingReplacement[device.id] if (!pending) { state.pendingReplacement[device.id] = [ stagedAt: now(), oldLevel: oldLevel, newLevel: newLevel, jumpSize: largeJump ] state.pendingReplacement = state.pendingReplacement if (debugMode) log.debug "${device.displayName}: replacement staged (awaiting confirmation) β€” ${oldLevel}% β†’ ${newLevel}%, jump=${largeJump}%" return } def windowMs = confirmWindowHrs * 60 * 60 * 1000 def pendingAge = now() - (pending.stagedAt as Long) if (pendingAge > windowMs) { if (debugMode) log.debug "${device.displayName}: pending replacement expired (${(pendingAge / 3600000).toInteger()}h > ${confirmWindowHrs}h window) β€” re-staging" state.pendingReplacement[device.id] = [ stagedAt: now(), oldLevel: oldLevel, newLevel: newLevel, jumpSize: largeJump ] state.pendingReplacement = state.pendingReplacement return } // Gate 5: level must still be above (pre-jump level + minJump) on confirming read def sustainThresh = (pending.oldLevel as Integer) + minJump if (newLevel < sustainThresh) { if (debugMode) log.debug "${device.displayName}: pending replacement cancelled β€” level dropped back to ${newLevel}% (must sustain β‰₯${sustainThresh}%)" state.pendingReplacement.remove(device.id) state.pendingReplacement = state.pendingReplacement return } state.pendingReplacement.remove(device.id) state.pendingReplacement = state.pendingReplacement data.zeroCount = 0 logReplacement(device, newLevel, false) if (debugMode) log.debug "${device.displayName}: replacement CONFIRMED β€” ${pending.oldLevel}% β†’ ${newLevel}%, staged ${(pendingAge / 60000).toInteger()}m ago" } // ============================================================ // ===================== CONFIRM PENDING REPLACEMENTS ======== // ============================================================ def confirmPendingReplacements() { if (!state.pendingReplacement || state.pendingReplacement.isEmpty()) return def minJump = (settings?.detectionMinJump ?: 30).toInteger() def windowMs = 48 * 60 * 60 * 1000 def toRemove = [] state.pendingReplacement.each { deviceId, pending -> def device = autoDevices?.find { it.id == deviceId } if (!device) { toRemove << deviceId; return } def currentLevel = device.currentValue("battery")?.toInteger() if (currentLevel == null) return def pendingAge = now() - (pending.stagedAt as Long) def sustainThresh = (pending.oldLevel as Integer) + minJump if (pendingAge > windowMs) { if (debugMode) log.debug "${device.displayName}: pending replacement EXPIRED during scan (${(pendingAge / 3600000).toInteger()}h old)" toRemove << deviceId return } if (currentLevel >= sustainThresh) { def histData = state.history[device.id] if (histData) histData.zeroCount = 0 logReplacement(device, currentLevel, false) toRemove << deviceId if (debugMode) log.debug "${device.displayName}: replacement CONFIRMED by scan β€” level ${currentLevel}% sustained β‰₯${sustainThresh}%" } else { if (debugMode) log.debug "${device.displayName}: pending replacement DISCARDED by scan β€” level ${currentLevel}% dropped below sustain threshold ${sustainThresh}%" toRemove << deviceId } } if (toRemove) { toRemove.each { state.pendingReplacement.remove(it) } state.pendingReplacement = state.pendingReplacement } } // ============================================================ // ===================== TREND LOGIC ========================= // ============================================================ def updateTrend(device, drain) { if (!device || drain == null) return def devType = (device?.name ?: device?.typeName ?: "").toLowerCase() def isLock = devType.contains("lock") def isSensor = devType.contains("contact") || devType.contains("motion") def isSlowSensor = devType.contains("smoke") || devType.contains("carbonmonoxide") def adjustedDrain = isLock ? drain * 0.4 : isSensor ? drain * 0.5 : isSlowSensor ? drain * 0.5 : drain if (adjustedDrain > 5) adjustedDrain = 0.3 def hist = state.history[device.id] if (hist?.samples && hist.samples.size() >= 3) { def avg = hist.samples.sum() / hist.samples.size() if (avg > 3) adjustedDrain = Math.min(adjustedDrain, 1.0) } def stableThreshold = isLock ? 0.9 : (isSensor || isSlowSensor) ? 0.6 : 0.3 def moderateThreshold = isLock ? 2.0 : (isSensor || isSlowSensor) ? 1.5 : 0.8 if (adjustedDrain <= stableThreshold) state.trend[device.id] = "Stable" else if (adjustedDrain < moderateThreshold) state.trend[device.id] = "Moderate" else state.trend[device.id] = "Heavy Drain" } // ============================================================ // ===================== CONFIDENCE HELPERS ================== // ============================================================ def getConfidence(device) { def samples = state.history?.get(device.id)?.samples?.size() ?: 0 def minN = 5 if (samples < 2) return 0.05 if (samples >= minN) return 1.0 return Math.min(1.0, 0.05 + 0.95 * Math.pow((samples - 1) / (minN - 1.0), 1.5)) } def getSampleQualityLabel(device, healthStr) { if (healthStr == "Pending") return null def conf = getConfidence(device) def label = conf < 0.20 ? "Low" : conf < 0.60 ? "Medium" : conf < 1.0 ? "High" : "Full" return "${label}" } // ============================================================ // ===================== DRAIN / HEALTH HELPERS ============== // ============================================================ def getDrain(device) { def d = state.history?.get(device.id)?.drain return (d != null && d > 0) ? d : 0.3 } def displayDrain(device) { return String.format("%.2f", getDrain(device)) } def estDays(device) { if (health(device) == "Pending") return null def level = device.currentValue("battery") != null ? device.currentValue("battery").toInteger() : 100 def drain = getDrain(device) if (drain <= 0) drain = 0.3 def est = Math.round(level / drain) return Math.min(est, 365) } def health(device) { def hist = state.history?.get(device.id) def samples = hist?.samples?.size() ?: 0 def devType = (device?.name ?: device?.typeName ?: "").toLowerCase() def isLock = devType.contains("lock") def isSensor = devType.contains("contact") || devType.contains("motion") def isSlowSensor = devType.contains("smoke") || devType.contains("carbonmonoxide") def minSamples = (isLock || isSlowSensor) ? 7 : 5 def daysSinceReplaced = 999 if (hist?.replacedTime) { daysSinceReplaced = (now() - (hist.replacedTime as Long)) / (1000 * 60 * 60 * 24) } else if (hist?.firstSeenDate) { daysSinceReplaced = (now() - (hist.firstSeenDate as Long)) / (1000 * 60 * 60 * 24) } else if (hist?.lastDate) { daysSinceReplaced = (now() - (hist.lastDate as Long)) / (1000 * 60 * 60 * 24) } def slowReporter = (daysSinceReplaced >= 14 && samples >= 2) if (!slowReporter && (samples < minSamples || daysSinceReplaced < 5)) return "Pending" def rawDrain = getDrain(device) def adjustedDrain = isLock ? rawDrain * 0.4 : isSensor ? rawDrain * 0.5 : isSlowSensor ? rawDrain * 0.5 : rawDrain def conf = getConfidence(device) def effDrain = 0.3 + conf * (adjustedDrain - 0.3) if (effDrain < 0.3) return "Excellent" if (effDrain <= 0.8) return "Good" if (effDrain <= 1.5) return "Fair" return "Poor" } def getHealthDisplay(device) { def h = health(device) def hist = state.history?.get(device.id) def samples = hist?.samples?.size() ?: 0 def devType = (device?.name ?: device?.typeName ?: "").toLowerCase() def isLock = devType.contains("lock") def isSlowSensor = devType.contains("smoke") || devType.contains("carbonmonoxide") def minSamples = (isLock || isSlowSensor) ? 7 : 5 if (h == "Pending") { def daysSinceReplaced = 0 if (hist?.replacedTime) { daysSinceReplaced = ((now() - (hist.replacedTime as Long)) / (1000 * 60 * 60 * 24)).toInteger() } else if (hist?.firstSeenDate) { daysSinceReplaced = ((now() - (hist.firstSeenDate as Long)) / (1000 * 60 * 60 * 24)).toInteger() } else if (hist?.lastDate) { daysSinceReplaced = ((now() - (hist.lastDate as Long)) / (1000 * 60 * 60 * 24)).toInteger() } def minDays = 5 return "⏳ ${samples}/${minSamples} samples  Β·  ${daysSinceReplaced}/${minDays} days" } def colorMap = ["Excellent": "#22c55e", "Good": "#22c55e", "Fair": "#f97316", "Poor": "#ef4444"] def color = colorMap[h] ?: "#94a3b8" return "${h}" } // ============================================================ // ===================== SAFE HISTORY HELPERS ================ // ============================================================ def safeTime(ts) { return (ts instanceof Number) ? ts : ts?.time } def safeHistory(device) { if (!device) return [:] def data = state.history?.get(device.id) if (!data) { def currentLevel = device.currentValue("battery") data = [ lastLevel: currentLevel != null ? currentLevel.toInteger() : 100, lastDate: now(), lastScanDate: now(), drain: 0.3, samples: [], justReplaced: false, zeroCount: 0 ] state.history[device.id] = data state.trend[device.id] = "Stable" } return data } def getLastBatteryTime(device) { return safeTime(state.history[device.id]?.lastScanDate ?: state.history[device.id]?.lastDate) } def getLastActivityTime(device) { return safeTime(device.getLastActivity()) } def getCatalogBatteryInfo(device) { if (!device) return null def battType = settings["battType_${device.id}"] def battCount = settings["battCount_${device.id}"] if (battType && battType != "" && !battType.startsWith("_sep")) { def count = (battCount != null && battCount.toString().trim() != "") ? battCount.toString().trim() : "1" def resolvedType = (battType == "Other") ? (settings["battCustomType_${device.id}"]?.trim() ?: "Other") : battType return "${resolvedType} x${count}" } def info = settings["battInfo_${device.id}"] if (!info || info == "" || info.startsWith("_sep")) return null return info } def isStale(device) { def lastActivity = getLastActivityTime(device) if (!lastActivity) return false def threshold = (settings?.staleThresholdHours != null && settings.staleThresholdHours > 0) ? settings.staleThresholdHours : 24 def diffHours = (now() - lastActivity) / (1000 * 60 * 60) return diffHours >= threshold } def formatTimeAgo(ts) { if (!ts) return "N/A" ts = safeTime(ts) def diffMs = now() - ts def mins = (diffMs / (1000 * 60)).toInteger() def hours = (diffMs / (1000 * 60 * 60)).toInteger() def days = (diffMs / (1000 * 60 * 60 * 24)).toInteger() def weeks = (days / 7).toInteger() def months = (days / 30).toInteger() if (months >= 1) return "${months}mo ago" if (weeks >= 1) return "${weeks}w ago" if (days >= 1) return "${days}d ago" if (hours >= 1) return "${hours}h ago" return "${mins}m ago" } def formatInactive(ts) { if (!ts) return "unknown" ts = safeTime(ts) def diffMs = now() - ts def mins = (diffMs / (1000 * 60)).toInteger() def hours = (diffMs / (1000 * 60 * 60)).toInteger() def days = (diffMs / (1000 * 60 * 60 * 24)).toInteger() def weeks = (days / 7).toInteger() def months = (days / 30).toInteger() if (months >= 1) return "${months}mo" if (weeks >= 1) return "${weeks}w" if (days >= 1) return "${days}d" if (hours >= 1) return "${hours}h" return "${mins}m" } // ============================================================ // ===================== BATTERY DISPLAY ===================== // ============================================================ def getBatteryLevelDisplay(level, device = null) { if (device && isBatteryDead(device)) return "πŸͺ« Dead" level = (level instanceof Number ? level : null) != null ? level : 100 def cat = level >= 100 ? "🟒 Excellent" : level > 70 ? "🟒 Good" : level > 25 ? "🟠 Fair" : "πŸ”΄ Poor" def label = "${cat} (${level}%)" def data = (device && state.history?.containsKey(device.id)) ? safeHistory(device) : null def showTag = data?.justReplaced == true def replacedTime = data?.replacedTime if (showTag) { replacedTime = safeTime(replacedTime) def hoursSinceReplacement = (now() - replacedTime) / (1000 * 60 * 60) if (hoursSinceReplacement >= 24) { if (data) data.justReplaced = false; showTag = false } } if (device && showTag) label += " (Recently Replaced)" return label } // ============================================================ // ===================== BATTERY REPLACEMENT LOGGER ========== // ============================================================ def logReplacement(device, newLevel, manual = false) { if (!device) return def data = state.history[device.id] if (!data) { state.history[device.id] = [ lastLevel: newLevel != null ? newLevel : 100, lastDate: now(), lastScanDate: now(), drain: 0.3, samples: [], justReplaced: false, zeroCount: 0 ] data = state.history[device.id] state.trend[device.id] = "Stable" } data.drain = 0.3 data.samples = [] data.lastLevel = newLevel data.lastDate = now() data.lastScanDate = now() data.firstSeenDate = now() data.justReplaced = true data.replacedTime = now() data.zeroCount = 0 state.trend[device.id] = "Stable" data.lastReplacementLogged = now() state.replacements = state.replacements?.findAll { it.device != device.displayName } ?: [] state.replacements << [ deviceId: device.id, device: device.displayName, level: newLevel, date: new Date().format("MM/dd/yyyy", location.timeZone), type: manual ? "manual" : "auto" ] state.replacements = state.replacements.sort { a, b -> b.date <=> a.date } state.history[device.id] = data state.history = state.history def typeStr = manual ? "Manual" : "Auto-detected" } // ============================================================ // ===================== OAUTH PORTAL HELPERS ================ // ============================================================ def getPortalRedirectHtml(delayMs, msgText) { return "" + "" + "" + "

πŸ”„ Refreshing...

${msgText}

" } // ============================================================ // ===================== PORTAL ENDPOINT: REFRESH ============ // ============================================================ def forceRefreshEndpoint() { try { runIn(1, "scanAllDevices", [overwrite: true]) return render(contentType: "text/html", data: getPortalRedirectHtml(2500, "Running battery scan..."), status: 200) } catch (e) { log.error "Battery Monitor portal refresh error: ${e}" return render(contentType: "text/html", data: "Error: ${e.message}", status: 500) } } // ============================================================ // ===================== PORTAL ENDPOINT: DASHBOARD ========== // ============================================================ def serveDashboardPage() { try { def devList = (autoDevices ?: []).findAll { it?.currentValue("battery") != null && !isIgnored(it) } devList = devList.sort { a, b -> def levelA = a.currentValue("battery") != null ? a.currentValue("battery").toInteger() : 100 def levelB = b.currentValue("battery") != null ? b.currentValue("battery").toInteger() : 100 levelA != levelB ? levelA <=> levelB : a.displayName.trim() <=> b.displayName.trim() } def totalCount = devList.size() def poorCount = devList.count { it.currentValue("battery") != null && it.currentValue("battery").toInteger() <= 25 && !isBatteryDead(it) } def deadCount = devList.count { isBatteryDead(it) } def staleCount = devList.count { isStale(it) } def highDrainCount = devList.count { device -> def h = health(device) (h == "Poor" || h == "Fair") && getDrain(device) > 1.5 } def css = """ body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;padding:20px;background:#0d0d0d;color:#e0e0e0;margin:0} .container{max-width:820px;margin:0 auto;background:#151515;padding:25px;border-radius:12px;box-sizing:border-box} h2{text-align:center;color:#fff;margin:0 0 4px 0} .subtitle{text-align:center;font-size:12px;color:#666;margin-bottom:20px} .summary-box{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:20px} .summary-card{flex:1;min-width:90px;box-sizing:border-box;background:#1e1e1e;padding:12px;border-radius:8px;text-align:center;border-bottom:3px solid #333} .summary-card b{display:block;font-size:22px;color:#fff;margin-bottom:4px} .summary-card span{font-size:11px;color:#aaa;text-transform:uppercase} .btn{display:block;background:#1f618d;color:#fff;padding:13px 20px;border-radius:8px;text-align:center;text-decoration:none;font-weight:600;margin-bottom:10px} .btn:hover{background:#1a5276} table{width:100%;border-collapse:collapse;font-size:13px;margin-top:15px} th{background:#1e1e1e;color:#aaa;padding:8px 6px;text-align:left;border-bottom:2px solid #333;font-size:11px;text-transform:uppercase} td{padding:8px 6px;border-bottom:1px solid #222;vertical-align:middle} tr:hover td{background:#1a1a1a} .badge{display:inline-block;padding:3px 8px;border-radius:10px;font-size:11px;font-weight:bold} .badge-poor{background:#3b1212;color:#ef4444} .badge-fair{background:#3b2a12;color:#f97316} .badge-good{background:#12301a;color:#22c55e} .badge-excellent{background:#12301a;color:#22c55e} .badge-dead{background:#3b1212;color:#ef4444} .badge-pending{background:#1e1e1e;color:#94a3b8} .batt-bg{width:80px;background:#333;height:5px;border-radius:3px;overflow:hidden;display:inline-block;vertical-align:middle;margin-left:6px} .batt-fg{height:100%} .stale-tag{font-size:10px;color:#f97316;margin-left:4px} .section-title{color:#fff;font-size:15px;font-weight:bold;margin:20px 0 8px 0;border-bottom:1px solid #333;padding-bottom:6px} """ StringBuilder html = new StringBuilder() html.append("") html.append("Battery Monitor Portal") html.append("") html.append("
") html.append("

πŸ”‹ Battery Monitor

") html.append("

Live Dashboard  Β·  Auto-refreshes every 2 min

") html.append("
") html.append("
${poorCount}Low Battery
") html.append("
${staleCount}Stale
") html.append("
${highDrainCount}High Drain
") html.append("
${deadCount}Dead
") html.append("
${totalCount}Total
") html.append("
") html.append("πŸ”„ Force Scan Now") html.append("
All Devices
") html.append("") html.append("") html.append("") devList.each { device -> def dead = isBatteryDead(device) def level = device.currentValue("battery") != null ? device.currentValue("battery").toInteger() : 0 def h = health(device) def drain = getDrain(device) def est = estDays(device) def stale = isStale(device) def catalog = getCatalogBatteryInfo(device) ?: "β€”" def lastActMs = getLastActivityTime(device) def lastAct = lastActMs ? formatTimeAgo(lastActMs) : "N/A" def badgeCls = dead ? "badge-dead" : h == "Poor" ? "badge-poor" : h == "Fair" ? "badge-fair" : h == "Good" ? "badge-good" : h == "Excellent" ? "badge-excellent" : "badge-pending" def badgeLbl = dead ? "πŸͺ« Dead" : h def barColor = level > 50 ? "#22c55e" : level > 25 ? "#f97316" : "#ef4444" def drainStr = (dead || h == "Pending") ? "β€”" : "${String.format('%.2f', drain)}%" def estStr = (dead || h == "Pending" || est == null) ? "β€”" : "${est}d" def staleHtml = stale ? "⚠ Stale" : "" html.append("") html.append("") html.append("") html.append("") html.append("") html.append("") html.append("") html.append("") html.append("") } html.append("
DeviceBatteryHealthDrainEst DaysLast ActivityType
${device.displayName}${staleHtml}${level}%
${badgeLbl}${drainStr}${estStr}${lastAct}${catalog}
") html.append("

Battery Monitor v2.5.29  Β·  jdthomas24

") html.append("
") return render(contentType: "text/html", data: html.toString(), status: 200) } catch (Exception e) { log.error "Battery Monitor portal error: ${e}" return render(contentType: "text/html", data: "

Portal Error

${e}

", status: 500) } } // ============================================================ // ===================== SUMMARY PAGE ======================== // ============================================================ def summaryPage() { dynamicPage(name: "summaryPage", title: "Battery Summary", install: false) { if (!state.history || !autoDevices || autoDevices.size() == 0) { section("Setup Required") { paragraph "⚠ Setup Not Complete

" + "You must click Done after selecting your devices before viewing reports.

" + "Please exit the app and reopen it, then try again." } return } def hubIp = location?.hub?.localIP ?: "" section("") { paragraph rawHtml: true, """ """ paragraph "⚠ Device links below are accessible only on your local network (LAN). They will not work remotely." paragraph "ΒΉ Last Battery shows when this app last received a battery reading β€” from a scheduled scan or device event. It is independent of Last Activity. A device can be active recently but still show an old Last Battery timestamp if its battery level has not changed or reported." href(name: "toForceScanFromSummary", page: "forceScanPage", title: "πŸ”„ Force Scan Now", description: "Tap to immediately read battery levels from all monitored devices") def devList = (autoDevices ?: []).findAll { try { it?.currentValue("battery") != null && !isIgnored(it) } catch (e) { log.warn "Error checking battery capability for ${it?.displayName}: ${e.message}" return false } } devList = devList.sort { a, b -> def levelA = null def levelB = null try { levelA = a.currentValue("battery") != null ? a.currentValue("battery").toInteger() : 100 } catch (e) { levelA = 100 } try { levelB = b.currentValue("battery") != null ? b.currentValue("battery").toInteger() : 100 } catch (e) { levelB = 100 } levelA != levelB ? levelA <=> levelB : (a.displayName ?: "") <=> (b.displayName ?: "") } if (!devList) { paragraph "No battery devices found."; return } def table = "" table += "" table += "" table += "" table += "" table += "" table += "" table += "" table += "" table += "" table += "" def summaryRowNum = 0 devList.each { device -> def dead = isBatteryDead(device) def level = null try { level = device.currentValue("battery") != null ? device.currentValue("battery").toInteger() : 100 } catch (e) { level = 100 } def catalogInfo = "" try { catalogInfo = getCatalogBatteryInfo(device) ?: "" } catch (e) { } def drain = 0.3 try { drain = getDrain(device) } catch (e) { } def est = null try { est = estDays(device) } catch (e) { } def lastBatteryStr = "N/A" def lastBatteryMs = 0 try { def lastBatteryTime = getLastBatteryTime(device) lastBatteryMs = lastBatteryTime ?: 0 lastBatteryStr = formatTimeAgo(lastBatteryTime) } catch (e) { } def lastActivityStr = "N/A" def lastActivityMs = 0 def lastActivity = null try { lastActivity = device.getLastActivity() } catch (e) { } if (lastActivity) { try { lastActivityMs = safeTime(lastActivity) ?: 0 lastActivityStr = formatTimeAgo(safeTime(lastActivity)) } catch (e) { } } def stale = false try { stale = isStale(device) } catch (e) { } def color = "" try { color = getBatteryLevelDisplay(level, device) } catch (e) { color = "${level}%" } def staleTag = (stale && lastActivity) ? " ⚠️ Stale" : "" def healthDisplay = "" try { healthDisplay = getHealthDisplay(device) } catch (e) { healthDisplay = "Unknown" } def name = device.displayName ?: "Unknown Device" def summaryRowBg = (summaryRowNum % 2 == 0) ? "#ffffff" : "#ebebeb" summaryRowNum++ table += "" def sortName = name.toLowerCase() if (hubIp) { table += "" } else { table += "" } table += "" if (dead) { table += "" table += "" table += "" } else if (health(device) == "Pending") { table += "" table += "" def healthOrder = 99 table += "" } else { table += "" def estDisplay = est != null ? est.toString() : "β€”" def estOrder = est != null ? est : 9999 table += "" def healthOrder = health(device) == "Poor" ? 4 : health(device) == "Fair" ? 3 : health(device) == "Good" ? 2 : health(device) == "Excellent" ? 1 : 99 table += "" } table += "" table += "" table += "" table += "" } table += "
DeviceBatteryDrain %/dayEst DaysBattery HealthLast BatteryΒΉLast ActivityBattery Type
${name}${name}${color}β€”β€”β€”πŸ“ˆπŸ“ˆ${healthDisplay}${String.format('%.2f', drain)}${estDisplay}${healthDisplay}${lastBatteryStr}${lastActivityStr}${staleTag}${catalogInfo}
" paragraph rawHtml: true, """
ℹ️ Drain and Est Days show πŸ“ˆ for Pending devices β€” the app is actively learning. Both show β€” for πŸͺ« Dead batteries. Data populates automatically once the Pending gate clears.
${table}
""" } } } // ============================================================ // ===================== DEVICE MANAGE PAGE ================== // ============================================================ def deviceManagePage(Map params = [:]) { def devList = (autoDevices ?: []).sort { a, b -> a.displayName.trim() <=> b.displayName.trim() } def ignoredCount = (settings?.ignoredDevices?.size() ?: 0) def typeOptions = ["": "β€” Not Set β€”"] typeOptions["_sep1"] = "──────── Standard ────────" ["AA", "AAA", "CR2", "CR1632", "CR2016", "CR2032", "CR2430", "CR2450", "CR2477", "CR123A", "9V", "ER14250", "LS14250"].each { typeOptions[it] = it } typeOptions["Integrated"] = "Integrated" typeOptions["_sep2"] = "──────── Rechargeable ────────" ["Rechargeable AA", "Rechargeable AAA", "LIR2016", "LIR2032", "LIR2430", "LIR2450", "18650"].each { typeOptions[it] = it } typeOptions["_sep3"] = "──────── Other ────────" typeOptions["Other"] = "Other" dynamicPage(name: "deviceManagePage", title: "πŸ”‹ Device Battery Management", install: false) { section("Actions") { href(name: "toDeviceActions", page: "deviceActionsPage", title: "βš™οΈ Device Actions", description: "Log a replacement, reset drain history, change battery type, or view history for a single device. Last selected device is remembered.") href(name: "toBulkActions", page: "bulkActionsPage", title: "πŸ“¦ Bulk Actions", description: "Log replacements or reset drain history across multiple devices at once. Useful when swapping batteries in several devices at the same time.") } def ignoredSectionTitle = ignoredCount > 0 ? "🚫 Ignored Devices β€” ${ignoredCount} ignored" : "🚫 Ignored Devices" section("Configuration") { href(name: "toIgnoredDevices", page: "ignoredDevicesPage", title: ignoredSectionTitle, description: ignoredCount > 0 ? "${ignoredCount} device(s) ignored β€” excluded from all reports, notifications, and the web portal. Removing a device resets its history and logs a Restored entry." : "No devices ignored β€” excluded from all reports, notifications, stale checks, health scoring, and the web portal.") href(name: "toDetectionSettings", page: "detectionSettingsPage", title: "πŸ” Auto-Detection Settings", description: "Configure battery level thresholds for replacement detection.") href(name: "toBatteryTypes", page: "batteryTypesPage", title: "πŸ”‹ Battery Types", description: "Assign battery type and quantity to each monitored device.") } } } // ============================================================ // ===================== DETECTION SETTINGS PAGE ============= // ============================================================ def detectionSettingsPage() { dynamicPage(name: "detectionSettingsPage", title: "πŸ” Auto-Detection Settings", install: false) { section("") { paragraph "
" + "Batteries only drain β€” any significant upward jump in level means a new battery was installed. " + "Battery Monitor detects this automatically across two consecutive readings." + "
" input "detectionMinJump", "number", title: "Minimum upward jump % to detect a replacement:", description: "Default: 30. Any upward jump of this size or more across two readings will be logged as a replacement.", defaultValue: 30, range: "15..60", required: false paragraph "
" + "⚠️ Set too low (under 20%) risks false positives on noisy devices. 30% works well for most setups. " + "Manual logging is still available in Device Actions for edge cases." + "
" } } } // ============================================================ // ===================== BATTERY TYPES PAGE ================== // ============================================================ def batteryTypesPage() { def devList = (autoDevices ?: []).sort { a, b -> a.displayName.trim() <=> b.displayName.trim() } def typeOptions = ["": "β€” Not Set β€”"] typeOptions["_sep1"] = "──────── Standard ────────" ["AA", "AAA", "CR2", "CR1632", "CR2016", "CR2032", "CR2430", "CR2450", "CR2477", "CR123A", "9V", "ER14250", "LS14250"].each { typeOptions[it] = it } typeOptions["Integrated"] = "Integrated" typeOptions["_sep2"] = "──────── Rechargeable ────────" ["Rechargeable AA", "Rechargeable AAA", "LIR2016", "LIR2032", "LIR2430", "LIR2450", "18650"].each { typeOptions[it] = it } typeOptions["_sep3"] = "──────── Other ────────" typeOptions["Other"] = "Other" // v2.5.29: Split into two sections β€” unassigned first, assigned collapsed // Fix: "Other" without custom text entered counts as unassigned def unassigned = devList.findAll { dev -> def t = settings["battType_${dev.id}"] ?: "" !t || t.startsWith("_sep") || (t == "Other" && !(settings["battCustomType_${dev.id}"]?.trim())) } def assigned = devList.findAll { dev -> def t = settings["battType_${dev.id}"] ?: "" t && !t.startsWith("_sep") && !(t == "Other" && !(settings["battCustomType_${dev.id}"]?.trim())) } dynamicPage(name: "batteryTypesPage", title: "πŸ”‹ Battery Types", install: false) { section("") { paragraph "Assign a battery type and quantity to each device so Battery Monitor can include battery type in notifications and replacement history. " + "This helps you know exactly what to buy when a replacement is needed.

" + "Set the type and count for as many devices as you like, then tap Done to save." } def unassignedTitle = unassigned.size() > 0 ? "πŸ”‹ Not Yet Assigned β€” ${unassigned.size()} device(s)" : "πŸ”‹ Not Yet Assigned β€” βœ… All assigned" section(unassignedTitle, hideable: true, hidden: unassigned.size() == 0) { if (!unassigned) { paragraph "βœ… All devices have a battery type assigned." } else { unassigned.each { dev -> def currentCount = settings["battCount_${dev.id}"] ?: 1 def isOther = settings["battType_${dev.id}"] == "Other" def levelStr = "" try { def lvl = dev.currentValue("battery") levelStr = (lvl != null) ? " ${lvl}%" : " β€”" } catch (e) { levelStr = " β€”" } def deviceTitle = "${dev.displayName} ${levelStr} Β· Not set" // Two-column layout: type=5, qty=1 per device, two devices per row (5+1+5+1=12) // When Other is selected, drop to full-width for that device to fit the custom text field if (isOther) { input "battType_${dev.id}", "enum", title: deviceTitle, options: typeOptions, required: false, defaultValue: "", submitOnChange: true, width: 7 input "battCustomType_${dev.id}", "text", title: "Custom type:", description: "e.g. CR17450, 4SR44", required: false, defaultValue: settings["battCustomType_${dev.id}"] ?: "", width: 3 input "battCount_${dev.id}", "number", title: "Qty:", defaultValue: currentCount, required: false, range: "1..99", width: 2 } else { input "battType_${dev.id}", "enum", title: deviceTitle, options: typeOptions, required: false, defaultValue: "", submitOnChange: true, width: 4 input "battCount_${dev.id}", "number", title: "Qty:", defaultValue: currentCount, required: false, range: "1..99", width: 2 } } } } section("βœ… Assigned β€” ${assigned.size()} device(s)", hideable: true, hidden: true) { if (!assigned) { paragraph "No devices assigned yet." } else { assigned.each { dev -> def currentType = settings["battType_${dev.id}"] ?: "" def currentCount = settings["battCount_${dev.id}"] ?: 1 def currentInfo = getCatalogBatteryInfo(dev) def isOther = currentType == "Other" def levelStr = "" try { def lvl = dev.currentValue("battery") levelStr = (lvl != null) ? " ${lvl}%" : " β€”" } catch (e) { levelStr = " β€”" } def infoStr = currentInfo ?: "Not set" def deviceTitle = "${dev.displayName} ${levelStr} Β· ${infoStr}" if (isOther) { input "battType_${dev.id}", "enum", title: deviceTitle, options: typeOptions, required: false, defaultValue: currentType, submitOnChange: true, width: 7 input "battCustomType_${dev.id}", "text", title: "Custom type:", description: "e.g. CR17450, 4SR44", required: false, defaultValue: settings["battCustomType_${dev.id}"] ?: "", width: 3 input "battCount_${dev.id}", "number", title: "Qty:", defaultValue: currentCount, required: false, range: "1..99", width: 2 } else { input "battType_${dev.id}", "enum", title: deviceTitle, options: typeOptions, required: false, defaultValue: currentType, submitOnChange: true, width: 4 input "battCount_${dev.id}", "number", title: "Qty:", defaultValue: currentCount, required: false, range: "1..99", width: 2 } } } } } } // ============================================================ // ===================== IGNORED DEVICES PAGE ================ // ============================================================ def ignoredDevicesPage() { def devList = (autoDevices ?: []).sort { a, b -> a.displayName.trim() <=> b.displayName.trim() } def previouslyIgnored = state.ignoredDeviceIds ?: [] def currentIgnored = (settings?.ignoredDevices?.collect { it as String }) ?: [] def restoredIds = previouslyIgnored.findAll { !currentIgnored.contains(it) } def restoredNames = [] if (restoredIds) { restoredIds.each { deviceId -> def device = autoDevices?.find { it.id == deviceId } if (device) { def level = device.currentValue("battery") != null ? device.currentValue("battery").toInteger() : 100 state.history[device.id] = [ lastLevel: level, lastDate: now(), lastScanDate: now(), firstSeenDate: now(), replacedTime: now(), justReplaced: true, drain: 0.3, samples: [], zeroCount: 0 ] state.trend[device.id] = "Stable" state.history = state.history state.replacements = state.replacements ?: [] state.replacements << [ deviceId: device.id, device: device.displayName, level: level, date: new Date().format("MM/dd/yyyy", location.timeZone), type: "restored" ] state.replacements = state.replacements.sort { a, b -> b.date <=> a.date } state.replacements = state.replacements restoredNames << "${device.displayName} (${level}%)" if (debugMode) log.debug "Device restored from ignored list: ${device.displayName}" } } } state.ignoredDeviceIds = currentIgnored dynamicPage(name: "ignoredDevicesPage", title: "🚫 Ignored Devices", install: false) { section("") { paragraph "Select devices to ignore completely. Ignored devices are excluded from all reports, " + "notifications, stale checks, health scoring, and the web portal. They remain in your " + "monitored devices list and in any Hubitat rules.

" + "ℹ️ When a device is removed from this list, its drain history resets and a Restored entry is logged " + "in Battery Replacement History. The device starts fresh as if newly added." } section("Select Devices to Ignore") { input "ignoredDevices", "enum", title: "Ignored devices:", options: devList.collectEntries { dev -> def lvl = "" try { lvl = dev.currentValue("battery") != null ? " (${dev.currentValue("battery").toInteger()}%)" : "" } catch (e) { } [(dev.id): "${dev.displayName}${lvl}"] }, multiple: true, required: false, submitOnChange: true } if (restoredNames) { section("βœ… Devices Restored") { paragraph "
" + "${restoredNames.size()} device(s) restored β€” drain history reset, health set to ⏳ Pending, Restored entry logged in Battery Replacement History.

" + "" + restoredNames.collect { "β€’ ${it}" }.join("
") + "
" } } section("") { paragraph "Changes take effect immediately when you add or remove devices." } } } // ============================================================ // ===================== BULK ACTIONS PAGE =================== // ============================================================ def bulkActionsPage() { def devList = (autoDevices ?: []).sort { a, b -> a.displayName.trim() <=> b.displayName.trim() } def cooldownMs = 60000 def lastRun = state.bulkActionLastRun ?: 0 def elapsed = now() - lastRun def onCooldown = elapsed < cooldownMs def secondsLeft = onCooldown ? Math.ceil((cooldownMs - elapsed) / 1000).toInteger() : 0 dynamicPage(name: "bulkActionsPage", title: "πŸ“¦ Bulk Actions", install: false) { section("") { paragraph "Select multiple devices to log battery replacements or reset drain history in one shot. " + "Useful when swapping batteries across several devices at once.

" + "ℹ️ Each action has a 60-second cooldown after running to prevent accidental back-to-back runs." } section("Select Devices") { if (!devList) { paragraph "No monitored devices found." return } input "bulkSelectedDevices", "enum", title: "Devices:", options: devList.collectEntries { dev -> def lvl = "" try { lvl = dev.currentValue("battery") != null ? " (${dev.currentValue("battery").toInteger()}%)" : "" } catch (e) { } [(dev.id): "${dev.displayName}${lvl}"] }, multiple: true, required: false, submitOnChange: false } if (onCooldown) { section("Actions") { paragraph "
" + "⏱ Bulk actions are on cooldown β€” available again in ${secondsLeft}s. " + "This prevents accidental back-to-back runs.
" } return } section("Actions") { paragraph "Both actions below operate on the selected devices above. " + "You may confirm one or both β€” each executes independently." input "bulkReplaceConfirm", "bool", title: "βœ… Confirm β€” log battery replacement for all selected devices", defaultValue: false, submitOnChange: true input "bulkResetConfirm", "bool", title: "πŸ”„ Confirm β€” reset drain history for all selected devices (no replacement logged)", defaultValue: false, submitOnChange: true } def anyConfirmed = (settings?.bulkReplaceConfirm == true || settings?.bulkResetConfirm == true) def hasSelection = (settings?.bulkSelectedDevices?.size() ?: 0) > 0 if (anyConfirmed && !hasSelection) { section("") { paragraph "
" + "⚠️ No devices selected β€” please select at least one device above.
" } return } if (anyConfirmed && hasSelection) { section("") { href(name: "toBulkActionsResult", page: "bulkActionsResultPage", title: "β–Ά Apply β€” tap to execute and see results", description: "") } } } } // ============================================================ // ===================== BULK ACTIONS RESULT PAGE ============ // ============================================================ def bulkActionsResultPage() { def doReplace = settings?.bulkReplaceConfirm == true def doReset = settings?.bulkResetConfirm == true def selectedIds = settings?.bulkSelectedDevices ?: [] def replacedNames = [] def resetNames = [] def skippedNames = [] if (selectedIds) { def selectedDevices = autoDevices?.findAll { selectedIds.contains(it.id) } ?: [] selectedDevices.each { device -> try { def level = device.currentValue("battery") != null ? device.currentValue("battery").toInteger() : 100 if (doReplace) { logReplacement(device, level, true) replacedNames << "${device.displayName} (${level}%)" } else if (doReset) { def existing = state.history[device.id] ?: [:] state.history[device.id] = [ lastLevel: existing.lastLevel ?: level, lastDate: now(), lastScanDate: now(), firstSeenDate: existing.firstSeenDate ?: existing.replacedTime ?: existing.lastDate ?: now(), replacedTime: existing.replacedTime, justReplaced: existing.justReplaced ?: false, drain: 0.3, samples: [], zeroCount: 0 ] state.trend[device.id] = "Stable" state.history = state.history resetNames << device.displayName } } catch (e) { skippedNames << device.displayName log.warn "Bulk action failed for ${device.displayName}: ${e.message}" } } } state.bulkActionLastRun = now() app.updateSetting("bulkReplaceConfirm", [value: false, type: "bool"]) app.updateSetting("bulkResetConfirm", [value: false, type: "bool"]) app.updateSetting("bulkSelectedDevices", [value: [], type: "enum"]) dynamicPage(name: "bulkActionsResultPage", title: "πŸ“¦ Bulk Actions β€” Result", install: false) { if (!doReplace && !doReset) { section("Nothing to do") { paragraph "No actions were confirmed β€” nothing was changed. Tap back to return." } return } if (replacedNames) { section("βœ… Battery Replacements Logged") { paragraph "
" + "${replacedNames.size()} device(s) updated β€” replacement logged, drain history reset, health set to ⏳ Pending.

" + "" + replacedNames.collect { "β€’ ${it}" }.join("
") + "
" } } if (resetNames) { section("πŸ”„ Drain History Reset") { paragraph "
" + "${resetNames.size()} device(s) reset β€” drain history cleared, health set to ⏳ Pending. No replacement logged.

" + "" + resetNames.collect { "β€’ ${it}" }.join("
") + "
" } } if (skippedNames) { section("⚠️ Skipped") { paragraph "
" + "The following devices encountered an error and were skipped:

" + skippedNames.collect { "β€’ ${it}" }.join("
") + "
" } } section("") { paragraph "⏱ Bulk actions are on a 60-second cooldown. Tap back to return to the management page." } } } // ============================================================ // ===================== DEVICE ACTIONS PAGE ================= // ============================================================ def deviceActionsPage() { def devList = (autoDevices ?: []).sort { a, b -> a.displayName.trim() <=> b.displayName.trim() } def selectedId = settings?.ddDeviceId def device = selectedId ? autoDevices?.find { it.id == selectedId } : null if (selectedId && selectedId != settings?.ddLastDeviceId) { app.updateSetting("ddReplaceConfirm", [value: false, type: "bool"]) app.updateSetting("ddResetConfirm", [value: false, type: "bool"]) app.updateSetting("ddLastDeviceId", [value: selectedId, type: "string"]) } def typeOptions = ["": "β€” Not Set β€”"] typeOptions["_sep1"] = "──────── Standard ────────" ["AA", "AAA", "CR2", "CR1632", "CR2016", "CR2032", "CR2430", "CR2450", "CR2477", "CR123A", "9V", "ER14250", "LS14250"].each { typeOptions[it] = it } typeOptions["Integrated"] = "Integrated" typeOptions["_sep2"] = "──────── Rechargeable ────────" ["Rechargeable AA", "Rechargeable AAA", "LIR2016", "LIR2032", "LIR2430", "LIR2450", "18650"].each { typeOptions[it] = it } typeOptions["_sep3"] = "──────── Other ────────" typeOptions["Other"] = "Other" dynamicPage(name: "deviceActionsPage", title: "βš™οΈ Device Actions", install: false) { section("Select Device") { paragraph "Select a device to manage its battery type, log a replacement, reset drain history, or view replacement history.
" + "ℹ️ The last selected device is remembered. Change the dropdown to switch devices." input "ddDeviceId", "enum", title: "Device:", options: devList.collectEntries { [(it.id): it.displayName] }, required: false, submitOnChange: true } if (!device) { return } def level = null try { level = device.currentValue("battery") != null ? device.currentValue("battery").toInteger() : "?" } catch (e) { level = "?" } def h = health(device) def drain = displayDrain(device) def est = estDays(device) def catalogInfo = getCatalogBatteryInfo(device) ?: "Not set" def dead = isBatteryDead(device) def estStr = (est != null) ? "${est}d" : "β€”" def drainStr = (h == "Pending" || dead) ? "β€”" : "${drain}%/day" def healthStr = dead ? "πŸͺ« Dead" : getHealthDisplay(device) section("") { paragraph "
" + "${device.displayName}
" + "Battery: ${level}%  Β·  " + "Health: ${healthStr}  Β·  " + "Drain: ${drainStr}  Β·  " + "Est Days: ${estStr}  Β·  " + "Type: ${catalogInfo}
" } section("πŸ”‹ Battery Type") { paragraph "Changes save when you tap Done." input "battType_${device.id}", "enum", title: "Battery Type:", options: typeOptions, required: false, defaultValue: settings["battType_${device.id}"] ?: "", submitOnChange: true, width: 8 if (settings["battType_${device.id}"] == "Other") { input "battCustomType_${device.id}", "text", title: "Custom type:", description: "e.g. CR17450, 4SR44", required: false, defaultValue: settings["battCustomType_${device.id}"] ?: "", width: 8 } input "battCount_${device.id}", "number", title: "Qty:", defaultValue: settings["battCount_${device.id}"] ?: 1, required: false, range: "1..99", width: 4 } section("βœ… Log Manual Replacement") { paragraph "Logs a replacement entry, resets drain history, and restarts the learning period." input "ddReplaceConfirm", "bool", title: "Confirm β€” log battery replacement now", defaultValue: false, submitOnChange: true } if (settings?.ddReplaceConfirm == true) { section("Replacement Result") { def currentLevel = device.currentValue("battery") != null ? device.currentValue("battery").toInteger() : 100 logReplacement(device, currentLevel, true) app.updateSetting("ddReplaceConfirm", [value: false, type: "bool"]) paragraph "βœ… Battery replacement logged for ${device.displayName} at ${currentLevel}%. Drain history reset β€” health will show ⏳ Pending while fresh data is collected." } } section("πŸ”„ Reset Drain History") { paragraph "Clears drain samples and resets health to ⏳ Pending without logging a replacement. Use when a device shows incorrect Heavy Drain." input "ddResetConfirm", "bool", title: "Confirm β€” reset drain history without logging a replacement", defaultValue: false, submitOnChange: true } if (settings?.ddResetConfirm == true) { section("Reset Result") { def existing = state.history[device.id] ?: [:] state.history[device.id] = [ lastLevel: existing.lastLevel ?: (device.currentValue("battery") != null ? device.currentValue("battery").toInteger() : 100), lastDate: now(), lastScanDate: now(), firstSeenDate: existing.firstSeenDate ?: existing.replacedTime ?: existing.lastDate ?: now(), replacedTime: existing.replacedTime, justReplaced: existing.justReplaced ?: false, drain: 0.3, samples: [], zeroCount: 0 ] state.trend[device.id] = "Stable" state.history = state.history app.updateSetting("ddResetConfirm", [value: false, type: "bool"]) paragraph "βœ… Drain history reset for ${device.displayName}. Health will show ⏳ Pending while fresh samples are collected." } } section("πŸ“‹ Replacement History") { def deviceHistory = state.replacements?.findAll { r -> r.deviceId == device.id || r.device == device.displayName }?.sort { a, b -> b.date <=> a.date } if (!deviceHistory || deviceHistory.size() == 0) { paragraph "No replacements logged yet for this device." } else { def table = "" table += "" table += "" table += "" table += "" table += "" deviceHistory.eachWithIndex { r, idx -> def rowBg = (idx % 2 == 0) ? "#ffffff" : "#ebebeb" def typeTag = r.type == "manual" ? "Manual" : r.type == "auto" ? "Auto" : "?" table += "" table += "" table += "" table += "" table += "" } table += "
DateLevelType
${r.date}${r.level}%${typeTag}
" paragraph "
${table}
" } } } } // ============================================================ // ===================== TRENDS PAGE ========================= // ============================================================ def trendsPage() { dynamicPage(name: "trendsPage", title: "Battery Trends", install: false) { section("") { paragraph rawHtml: true, """ """ href(name: "toForceScanFromTrends", page: "forceScanPage", title: "πŸ”„ Force Scan Now", description: "Tap to immediately read battery levels from all monitored devices") def devList = (autoDevices ?: []).findAll { it?.currentValue("battery") != null && !isIgnored(it) } if (!devList) { paragraph "No battery devices found for trends."; return } def trendPriority = ["Heavy Drain": 1, "Moderate": 2, "Stable": 3] devList = devList.sort { a, b -> def deadA = isBatteryDead(a) def deadB = isBatteryDead(b) if (deadA != deadB) return deadA ? -1 : 1 def hA = health(a) def hB = health(b) def pendingA = hA == "Pending" def pendingB = hB == "Pending" if (pendingA != pendingB) return pendingA ? 1 : -1 def trendA = state.trend[a.id] ?: "Stable" def trendB = state.trend[b.id] ?: "Stable" def prioA = trendPriority[trendA] ?: 3 def prioB = trendPriority[trendB] ?: 3 if (prioA != prioB) return prioA <=> prioB def levelA = a.currentValue("battery") != null ? a.currentValue("battery").toInteger() : 100 def levelB = b.currentValue("battery") != null ? b.currentValue("battery").toInteger() : 100 if (levelA != levelB) return levelA <=> levelB return a.displayName.trim() <=> b.displayName.trim() } def hubIp = location?.hub?.localIP ?: "" def table = "" table += "" table += "" table += "" table += "" table += "" table += "" table += "" def trendsRowNum = 0 devList.each { device -> def dead = isBatteryDead(device) def hist = safeHistory(device) def level = device.currentValue("battery") != null ? device.currentValue("battery").toInteger() : 100 def drain = getDrain(device) def trend = state.trend[device.id] ?: "Unknown" def h = health(device) def trendColor = trend == "Heavy Drain" ? "πŸ”΄" : trend == "Moderate" ? "🟠" : "🟒" def color = getBatteryLevelDisplay(level, device) def healthDisplay = dead ? "β€”" : getHealthDisplay(device) def trendOrder = dead ? 1 : (h == "Pending" ? 999 : ((trendPriority[trend] ?: 4) + 1)) def healthOrder = dead ? 999 : (h == "Poor" ? 4 : h == "Fair" ? 3 : h == "Good" ? 2 : h == "Excellent" ? 1 : 99) def trendsRowBg = (trendsRowNum % 2 == 0) ? "#ffffff" : "#ebebeb" trendsRowNum++ table += "" if (hubIp) { table += "" } else { table += "" } table += "" if (dead) { table += "" table += "" } else if (h == "Pending") { table += "" table += "" } else { table += "" table += "" } table += "" table += "" } table += "
DeviceBatteryTrendDrain %/dayBattery Health
${device.displayName}${device.displayName}${color}β€”β€”πŸ“ˆπŸ“ˆ${trendColor} ${trend}${String.format('%.2f', drain)}${healthDisplay}
" paragraph rawHtml: true, """ ${hubIp ? "⚠ Device links below are accessible only on your local network (LAN). They will not work remotely.

" : ""}
ℹ️ Trend and Drain show πŸ“ˆ for Pending devices β€” the app is actively learning. Both show β€” for πŸͺ« Dead batteries. Data populates automatically once the Pending gate clears.
${table}
""" } } } // ============================================================ // ===================== HISTORY PAGE ======================== // ============================================================ def historyPage() { dynamicPage(name: "historyPage", title: "Battery Replacement History", install: false) { section("") { if (!state.replacements || state.replacements.size() == 0) { paragraph "No battery replacements have been logged yet." return } def table = "" table += "" table += "" table += "" table += "" table += "" table += "" table += "" state.replacements.sort { a, b -> b.date <=> a.date }.eachWithIndex { r, idx -> def historyRowBg = (idx % 2 == 0) ? "#ffffff" : "#ebebeb" def typeTag = r.type == "manual" ? "M" : r.type == "auto" ? "A" : r.type == "restored" ? "R" : "?" def dev = r.deviceId ? autoDevices?.find { it.id == r.deviceId } : autoDevices?.find { it.displayName == r.device } def orphaned = (dev == null) def info = dev ? getCatalogBatteryInfo(dev) : null def infoStr = info ? "${info}" : "" def displayName = dev ? dev.displayName : r.device def nameDisplay = orphaned ? "${displayName} (device removed)" : displayName table += "" table += "" table += "" table += "" table += "" table += "" table += "" } table += "
DeviceBattery TypeLevelDateType?
${nameDisplay}${infoStr}${r.level}%${r.date}${typeTag}
" paragraph "
${table}
" paragraph "Legend: A = Automatic, M = Manual, R = Restored from ignored" } section("Delete an Entry") { href(name: "toDeleteHistory", page: "deleteHistoryPage", title: "πŸ—‘οΈ Delete a History Entry") } } } // ============================================================ // ===================== DELETE HISTORY PAGE ================= // ============================================================ def deleteHistoryPage() { app.removeSetting("deleteEntrySelection") app.updateSetting("confirmEntryDelete", [value: false, type: "bool"]) dynamicPage(name: "deleteHistoryPage", title: "Delete a History Entry", install: false) { if (!state.replacements || state.replacements.size() == 0) { section() { paragraph "No replacement history to delete." } } else { def options = [:] state.replacements.sort { a, b -> b.date <=> a.date }.eachWithIndex { r, i -> options["${i}"] = "πŸ—‘οΈ ${r.device} β€” ${r.date}" } section("Select Entry to Delete") { input "deleteEntrySelection", "enum", title: "Choose entry", options: options, multiple: false, required: false } section("Confirm Deletion") { input "confirmEntryDelete", "bool", title: "Confirm deletion", defaultValue: false } section() { href(name: "toDeleteHistoryConfirm", page: "deleteHistoryConfirmPage", title: "Submit") } } } } // ============================================================ // ============= DELETE HISTORY CONFIRM PAGE ================= // ============================================================ def deleteHistoryConfirmPage() { dynamicPage(name: "deleteHistoryConfirmPage", title: "Delete Entry", install: false) { section("Result") { if (!confirmEntryDelete) { paragraph "⚠️ Deletion cancelled β€” confirm checkbox was not checked." } else if (deleteEntrySelection == null) { paragraph "⚠️ No entry selected." } else { def sorted = state.replacements.sort { a, b -> b.date <=> a.date } def idx = deleteEntrySelection.toInteger() if (idx >= 0 && idx < sorted.size()) { def entry = sorted[idx] state.replacements = state.replacements.findAll { !(it.device == entry.device && it.date == entry.date) } app.updateSetting("confirmEntryDelete", [value: false, type: "bool"]) paragraph "βœ… Deleted entry for ${entry.device} on ${entry.date}." } else { paragraph "⚠️ Entry not found β€” it may have already been deleted." } } } } } // ============================================================ // ============= SEND NOTIFICATION PAGE ====================== // ============================================================ def sendNotificationPage() { dynamicPage(name: "sendNotificationPage", title: "Send Notification", install: false) { def devList = autoDevices ?: [] def hasDevices = devList.size() > 0 def hasTargets = (settings?.notifyDevices?.size() ?: 0) > 0 || (settings?.pushoverDevices?.size() ?: 0) > 0 || (settings?.enablePush == true) def notifyOn = settings?.enablePush != false def snoozed = state.notifSnoozedUntil && state.notifSnoozedUntil >= now() if (!hasDevices) { section("Cannot Send") { paragraph "⚠️ No monitored devices are selected. Please go back to the main page, select devices, and tap Done before sending a notification." } return } if (!notifyOn) { section("Cannot Send") { paragraph "⚠️ Notifications are turned off. Enable the Notifications toggle on the main page before sending." } return } if (!hasTargets) { section("Cannot Send") { paragraph "⚠️ No notification devices are configured. Add at least one notification device on the main page before sending." } return } if (snoozed) { def hoursLeft = Math.ceil((state.notifSnoozedUntil - now()) / 3600000).toInteger() section("⚠️ Notifications Snoozed") { paragraph "😴 Notifications are currently snoozed for ${hoursLeft}h. This send will bypass the snooze and send immediately." } } section("Confirm") { paragraph "This will send a battery summary notification to all configured notification devices right now." input "sendNowConfirm", "bool", title: "βœ… Confirm β€” send the notification", defaultValue: false, submitOnChange: true } if (settings?.sendNowConfirm) { section("Result") { def savedSnooze = state.notifSnoozedUntil state.notifSnoozedUntil = 0 scheduledSummary() state.notifSnoozedUntil = savedSnooze app.updateSetting("sendNowConfirm", [value: false, type: "bool"]) def sentTo = [] if (settings?.notifyDevices) sentTo.addAll(settings.notifyDevices.collect { it.displayName }) if (settings?.pushoverDevices) sentTo.addAll(settings.pushoverDevices.collect { "${it.displayName} (Pushover)" }) if (sentTo) { paragraph "βœ… Notification sent to:\n" + sentTo.collect { "β€’ ${it}" }.join("\n") } else { paragraph "βœ… Notification sent via hub push." } } } } } // ============================================================ // ===================== FORCE SCAN PAGE ===================== // ============================================================ def forceScanPage() { scanAllDevices() if (debugMode) log.debug "Manual battery scan triggered by user" dynamicPage(name: "forceScanPage", title: "Force Scan", install: false) { section("Scan Complete") { def devList = (autoDevices ?: []).findAll { !isIgnored(it) } def count = devList.size() paragraph "βœ… Battery scan complete β€” ${count} device(s) read. " + "Return to Battery Summary or Trends to see updated values.

" + "Note: A new drain sample is only recorded if the battery level " + "has changed since the last reading. Devices reporting the same level " + "will not generate a new sample." } } } // ============================================================ // ===================== INFO PAGE =========================== // ============================================================ def infoPage(Map params = [:]) { dynamicPage(name: "infoPage", title: "App Guide & Reference", install: false) { section("🌐 Web Portal") { paragraph rawHtml: true, "
" + "Enable OAuth in App Code to unlock the web portal β€” Cloud and Local URLs appear on the main page once active. " + "The portal shows all devices sorted by battery level with health, drain, est days, last activity, and battery type. Auto-refreshes every 2 minutes.

" + "Add a Link tile to your Hubitat dashboard and paste in your Cloud or Local URL to access it directly.
" } section("πŸ”‘ Battery Level Ranges") { paragraph rawHtml: true, "
Battery level colors reflect current charge percentage. " + "Health ratings use the same color scheme but are based on drain rate β€” not battery percentage. " + "A device can show 🟒 Good battery level yet πŸ”΄ Poor health if it is draining unusually fast.

" + "
" + "" + "" + "" + "" + "" + "" + "
LevelRangeMeaning
🟒 Excellent100%Fully charged
🟒 Good71–99%Healthy β€” no action needed
🟠 Fair26–70%Getting low β€” keep an eye on it
πŸ”΄ Poor0–25%Replace soon
πŸͺ« Dead0% (confirmed)Battery confirmed dead β€” replace immediately
" } section("πŸ”‹ Battery Health & Trends") { paragraph rawHtml: true, "
Health is a long-term confidence-weighted average drain rate. " + "Trend uses the same thresholds but reacts faster to recent readings β€” a short spike may push Trend to πŸ”΄ Heavy Drain " + "while Health stays 🟒 Good until enough samples confirm the pattern.

" + "
" + "" + "" + "" + "" + "" + "" + "
HealthTrendDrain/dayWhat It Means
⏳ Pendingβ€”β€”Not enough data yet
🟒 Excellent🟒 Stable<= 0.3%Very efficient, minimal drain
🟒 Good🟠 Moderate0.3–0.8%Normal battery usage
🟠 FairπŸ”΄ Heavy Drain0.8–1.5%Above average β€” worth monitoring, no alert
πŸ”΄ PoorπŸ”΄ Heavy Drain> 1.5%High drain β€” High Drain alert fires

" + "Note: Door locks use higher drain thresholds than other devices. Locks showing 🟠 Moderate trend are not necessarily a concern unless drain is consistently very high.

" + "Slow drain devices: Smoke detectors, CO detectors, and other always-on low-power devices may show drain rates at or near 0.00%/day. This is normal β€” these devices are designed to run for 1–3 years on a single set of batteries. Est Days shows 365 as a maximum; actual battery life may be significantly longer.
" } section("⏳ Pending Health & Samples") { paragraph rawHtml: true, "
" + "Health shows ⏳ Pending until enough data is collected. Progress shows inline β€” for example: ⏳ 3/5 samples Β· 3/5 days

" + "Requires 5 samples and 5 days minimum (7 samples for locks, smoke, and CO detectors). " + "Devices that report infrequently clear Pending automatically after 14 days with 2+ samples.

" + "Confidence weighting: Early readings carry less weight β€” by 10 samples the full measured drain is used.
" } section("πŸ” Drain, Estimated Days & Last Battery") { paragraph rawHtml: true, "
" + "Drain = %/day based on the last 10 readings. Est Days = current level Γ· drain, capped at 365.

" + "Last Battery shows when the app last received a battery reading β€” independent of Last Activity.
" } section("😴 Notification Snooze") { paragraph rawHtml: true, "
" + "Silences all Battery Monitor notifications for a configurable number of days. " + "Scanning continues normally β€” only notifications are paused. " + "Manual Send Notification Now bypasses the snooze. Snooze expires automatically.
" } section("πŸ”‹ Device Battery Management") { paragraph rawHtml: true, "
" + "Assign battery types, log replacements, reset drain history, and view per-device history from the Reports menu.

" + "Bulk Actions: Log replacements or reset drain history across multiple devices at once. 60-second cooldown prevents accidental back-to-back runs.

" + "Ignored Devices: Excludes a device completely from all reports, notifications, stale checks, health scoring, and the portal. " + "Removing from the list resets history, logs a Restored (R) entry, and shows Recently Replaced for up to 24 hours.
" } section("πŸ”„ Force Scan & Replacement Detection") { paragraph rawHtml: true, "
" + "How it works: Batteries only drain naturally β€” they never recharge on their own. So any significant upward jump in battery level means a new battery was installed. " + "Battery Monitor watches for these jumps and logs a replacement automatically.

" + "Detection rules:
" + "β€’ Battery level jumps up by at least the configured minimum (default 30%)
" + "β€’ Jump is confirmed across two consecutive readings within 48 hours
" + "β€’ Device has 3+ prior drain samples and is 3+ days old
" + "β€’ 12-hour cooldown prevents duplicate detections

" + "A single spurious spike will not log a replacement β€” the two-reading confirmation catches noisy devices. " + "The minimum jump % is configurable under πŸ”‹ Device Battery Management β†’ Auto-Detection Settings.

" + "Manual logging is still available in Device Actions for edge cases: integrated batteries, " + "unreliable reporters, or replacements you want to back-date.

" + "Force Scan reads all battery levels immediately. A new drain sample only records if the level has changed since the last reading.
" } section("πŸ’‘ Tips for Best Results") { paragraph rawHtml: true, "
" + "β€’ Let new batteries run at least a week before trusting health ratings
" + "β€’ Assign battery types in πŸ”‹ Device Battery Management β€” used in notifications and the portal
" + "β€’ After replacing a battery, log it in πŸ”‹ Device Battery Management or use Bulk Actions for multiple devices
" + "β€’ Auto-detection logs replacements for any upward battery jump β‰₯ your configured minimum % (default 30%) confirmed across two readings
β€’ If a replacement isn't auto-detected, log it manually in Device Actions β€” useful for integrated batteries or unreliable reporters
" + "β€’ Use Ignored Devices for spare or storage devices you don't want to monitor
" + "β€’ Use Reset Drain History if a device shows incorrect Heavy Drain after first install
" + "β€’ Use Notification Snooze when traveling
" } } }