" +
"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 BETA 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. Li-ion cliff-drop alerts bypass this by default (configurable in Auto-Detection Settings)."
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 notifSectionTitle = "Notifications — ${notifOn ? "On" : "Off"}"
section(notifSectionTitle, hideable: true, hidden: true) {
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 & Trends", description: "Battery levels, health, drain rates and trends")
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")
paragraph rawHtml: true, """
" +
"📈 Drain and Est Days show 📈 for Pending devices — the app is actively learning. Data populates automatically once the Pending gate clears. 🪫 Dead = battery confirmed dead, replace immediately.
"
}
}
}
// ============================================================
// ===================== 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", "RCR123A", "RCR2"].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, ignore a device, change battery type, or view history. Last selected device is remembered.")
href(name: "toBulkActions", page: "bulkActionsPage",
title: "📦 Bulk Actions",
description: "Log replacements, reset drain history, or ignore multiple devices at once.")
}
def ignoredIds = (settings?.ignoredDevices?.collect { it as String }) ?: []
def ignoredNames = autoDevices?.findAll { ignoredIds.contains(it.id as String) }?.collect { it.displayName } ?: []
section("Configuration") {
if (ignoredNames) {
paragraph "
" +
"🚫 Ignored Devices (${ignoredNames.size()}) — excluded from all reports, notifications, and the portal. " +
ignoredNames.collect { "• ${it}" }.join(" ") + "
" +
"To restore a device, go to Device Actions and toggle its ignore setting. To restore multiple, use Bulk Actions → Unignore.
"
}
href(name: "toDetectionSettings", page: "detectionSettingsPage",
title: "🔍 Auto-Detection Settings",
description: "Configure the minimum battery jump % that triggers auto-detection, and the Li-ion cliff-drop alert threshold (BETA).")
href(name: "toBatteryTypes", page: "batteryTypesPage",
title: "🔋 Battery Types",
description: "Assign battery type and quantity to each monitored device. Also confirms Li-ion cliff-detection eligibility (BETA).")
}
}
}
// ============================================================
// ===================== 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 15%) may cause false positives even with two-reading confirmation. 25–30% works well for most setups.
" +
"Example: a jump from 40% → 75% (35%) logs as replaced. A jump from 85% → 90% (5%) does not.
" +
"Manual logging is still available in Device Actions for edge cases." +
"
"
}
section("🧪 BETA: Li-ion Cliff-Drop Detection") {
paragraph "Li-ion rechargeable cells (LIR/18650/RCR-type) tend to hold a flat charge right up until end of life, then fail suddenly, often crashing from a healthy level to near-zero within hours, sometimes with no gradual warning at all. " +
"This checks the raw drop between two consecutive readings, independent of the normal drain-averaging logic, so it can catch that pattern immediately rather than waiting for it to show up in long-term drain trends. " +
"It only applies to devices with the cliff-detection checkbox enabled on the Battery Types page."
input "cliffDropThreshold", "number",
title: "Minimum drop % between two readings to trigger an urgent alert:",
description: "Default: 40. Not time-boxed, applies to any two consecutive readings regardless of how far apart they were taken, so it still catches infrequent reporters.",
defaultValue: 40,
range: "15..90",
required: false
input "cliffBypassSnooze", "bool",
title: "😴 Bypass Notification Snooze for cliff alerts",
description: "Recommended on. A battery that's about to fail is a different kind of alert than routine reports, and snooze is meant for the latter.",
defaultValue: true
paragraph "
" +
"⚠️ This only fires for devices you've explicitly enabled on the Battery Types page. " +
"LIR-prefixed types are auto-enabled since that naming always means Li-ion rechargeable. " +
"18650, RCR123A, and RCR2 default OFF, since those are physical form-factor labels that are usually but not always Li-ion, enable manually once you've confirmed the actual chemistry." +
"
"
}
}
}
// ============================================================
// ===================== 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", "RCR123A", "RCR2"].each { typeOptions[it] = it }
typeOptions["_sep3"] = "──────── Other ────────"
typeOptions["Other"] = "Other"
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."
}
section("🧪 BETA: Li-ion Cliff Detection", hideable: true, hidden: true) {
paragraph "Each device below has a ⚡ Cliff Detection checkbox. When enabled, Battery Monitor watches for a sudden large drop between two consecutive readings on that device and fires an urgent alert, since that pattern is typical of a Li-ion cell nearing end of life.
" +
"LIR-prefixed types (LIR2016/2032/2430/2450) are always Li-ion rechargeable, so the checkbox defaults ON automatically once you assign one of those types.
" +
"18650, RCR123A, RCR2 are physical form-factor labels, not chemistry, most are Li-ion but not all (LiFePO4 variants exist in the same sizes), so the checkbox defaults OFF for these. Turn it on once you know your specific cell is Li-ion.
"
if (hubIp) paragraph "⚠ Device links accessible on local network (LAN) only."
}
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 }.take(100).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 }.take(100)
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 & 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("🧪 BETA: Li-ion Cliff-Drop Detection") {
paragraph rawHtml: true, "
" +
"Li-ion rechargeable cells (LIR/18650/RCR-type) tend to hold a flat charge for most of their life, then fail suddenly, sometimes crashing from a healthy level to near-dead within hours with little or no gradual warning.
" +
"This check looks at the raw drop between two consecutive readings, regardless of how much time passed between them, so it still catches infrequently-reporting devices. If the drop meets or exceeds the configured threshold (default 40%), an urgent notification fires immediately, separate from the normal daily/scheduled summary.
" +
"Only applies to devices with the ⚡ Cliff Detection checkbox enabled, set on the Battery Types or Device Actions page. LIR-prefixed types (always Li-ion rechargeable) are auto-enabled. 18650, RCR123A, and RCR2 are form-factor labels that are usually but not always Li-ion, so they default off, enable manually once you've confirmed the chemistry. All other types default off but the checkbox is available for any device, useful for custom/Other entries.
" +
"By default, cliff alerts bypass Notification Snooze, since this alert type is meant to be urgent rather than routine. This is configurable in Auto-Detection Settings.
" +
"This is a separate mechanism from the dead-battery check and the long-term drain average. A Li-ion cell can look perfectly healthy (Excellent/Good, low drain) right up until the cliff, that's expected and not a bug, it's the nature of the chemistry.
"
}
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 — slow to change by design. It answers: how efficiently has this battery been used overall?
" +
"Trend reacts faster to recent readings. It answers: what is this battery doing right now?
" +
"In the Health & Trend column: " +
"• When Health and Trend agree, only Health is shown — no noise " +
"• When Trend is worse than Health, a ⚠ warning appears alongside Health — this is the most actionable signal, meaning something has recently changed
" +
"
" +
"
Health
Drain/day
What It Means
" +
"
⏳ Pending
—
Not enough data yet — still learning
" +
"
🟢 Excellent
<= 0.3%
Very efficient, minimal drain
" +
"
🟢 Good
0.3–0.8%
Normal battery usage
" +
"
🟠 Fair
0.8–1.5%
Above average — worth monitoring
" +
"
🔴 Poor
> 1.5%
High drain — notification fires
" +
"
" +
"When trend is active it shows as Moderate Drain or Heavy Drain next to the health rating. " +
"Moderate Drain means drain is elevated but not yet at the notification threshold. Heavy Drain means drain is high enough to trigger the High Drain notification.
" +
"Example: A device showing Good ⚠ Heavy Drain has a solid long-term history but is draining unusually fast right now — worth watching before it becomes Poor.
" +
"Note: Door locks use higher drain thresholds. Locks showing a Moderate warning are not necessarily a concern unless drain is consistently high.
" +
"Slow drain devices: Smoke and CO detectors may show 0.00%/day drain. This is normal — they run for 1–3 years on a single set. Est Days is capped at 365; actual life may be longer.
" +
"Li-ion note: The Health/Trend/Drain system above is a long-term average and is not built to catch sudden end-of-life crashes. See the Cliff-Drop Detection (BETA) section above for that.
" +
"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.
" +
"BETA: Li-ion cliff-drop alerts also bypass snooze by default. This is configurable per-install in Auto-Detection Settings.
" +
"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, the portal, and BETA cliff detection " +
"• 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 " +
"• BETA: assign the correct battery type first, cliff detection eligibility and defaults are driven entirely by that assignment