" +
"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." +
"
" +
"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 = "
βΉοΈ 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.
"
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 = "
"
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.
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.
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.
" +
"
" +
"
Health
Trend
Drain/day
What It Means
" +
"
β³ Pending
β
β
Not enough data yet
" +
"
π’ Excellent
π’ Stable
<= 0.3%
Very efficient, minimal drain
" +
"
π’ Good
π Moderate
0.3β0.8%
Normal battery usage
" +
"
π Fair
π΄ Heavy Drain
0.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.
" +
"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.
" +
"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.
" +
"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.
" +
"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