Yet another battery status app...
This one is based on 'Battery Notifier' by Bruce Ravenel, but heavily modified as a vehicle for me learning a bit about Groovy and ChatGPT (which admittedly did all the detail work).
Main features:
- Shows only devices that report a "battery" capability.
- Selectively shows devices based on low/critical battery, offline devices, last event (any type), last battery event, and last activity.
- Configurable reporting interval and reporting time.
- Output is in a sortable table for ease of reading.
- Parenthetical details in section headings can be suppressed.
- Multiple notification devices supported.
- Plain-text table for notifications (e.g., Pushover, Hubitat)
- Last run date displayed at bottom of UI.
- Configurable low battery warning and critical battery level.
- Users can select one or more reports via checkboxes.
- Each report has its own Sort By & Order.
- Tables automatically show when the app is opened.
- Can now suppress reporting devices in the "Last Battery Event" table if the device simply reports "NEVER" -- reduces table clutter.
- For Pushover notifications, can now specifiy that the first report goes to a "sound" device and other reports go to a "silent" device (must set that notification for that device to be "none").
2025-08-18 FIXES:
- Plain-text table for notification devices (e.g., Pushover, Hubitat)
- "[Never]" is red only in "Check Now" view, plain in notifications.
2025-08-19 FIXES:
- Some more cosmetic changes to the plain text "table" for notification devices
- More cosmetic changes to output tables
2025-08-22 Update
- Added "All Tables" option (sequentially outputs Any, Offline, Battery reports)
- Independent sorting per section when "All Tables" is selected
2025-09-05 Update
- Added a battery level table, configurable as to "low" and "critical" levels.
2025-09-06 Update: Fixed a bug that omitted reports when sent to a single notification device.
2025-09-19 Update: Cosmetic changes, and made the selected tables auto-generate when the app opens.
2025-10-24 Update: More cosmetic changes, and added a "Send Report Now" button.
2025-11-18 Update: Adds the ability to suppress reporting devices in the "Last Battery Event" table if the device simply reports "NEVER" -- reduces table clutter.
2026-01-01 Update: Mostly cosmetic fixes, plus a few minor bugs swatted.
2026-01-04 Update: Mostly cosmetic fixes, plus a few more minor bugs swatted.
CAVEAT: This is only my 2nd Groovy app, I'm a dabbler, not a professional programmer, and I have a VERY incomplete understanding of Groovy and the Hubitat API. Accordingly, constructive comments/critiques are welcome.
BTW, I tried (actually, ChatGPT tried multiple times) to get the table be sortable by clicking on the table column headings, but that didn't work at all. Any pointers on how that might be accomplished in the Groovy/Hubitat context would be appreciated.
Sample "Refresh Table" output:
The app code is below. Consider this to be a BETA app and use at your peril.
/*
PURPOSE: Check the state of battery based devices
FEATURES:
* Shows only devices that report a "battery" capability.
* Selectively shows devices based on low/critical battery, offline devices, last event (any type), last battery event, and last activity.
* Configurable reporting interval and reporting time.
* Output is in a sortable table for ease of reading.
* Parenthetical details in section headings can be suppressed.
* Multiple notification devices supported.
* Plain-text table for notifications (e.g., Pushover, Hubitat)
* Last run date displayed at bottom of UI.
* Configurable low battery warning and critical battery level.
* Users can select one or more reports via checkboxes.
* Each report has its own Sort By & Order.
* Tables automatically show when the app is opened.
* Can now suppress reporting devices in the "Last Battery Event" table if the device simply reports "NEVER" -- reduces table clutter.
* For Pushover notifications, can now specifiy that the first report goes to a "sound" device and
other reports go to a "silent" device (must set that notification for that device to be "none").
*/
definition(
name: "Battery Device Status 1.17",
namespace: "John Land",
author: "John Land via ChatGPT",
description: "Battery Device Status with battery %, offline/low battery reporting, last activity, configurable sort options",
installOnOpen: true,
category: "Convenience",
iconUrl: "",
iconX2Url: ""
)
preferences {
page(name: "mainPage")
}
def mainPage() {
def devCount = devs?.findAll { !it.isDisabled() }?.size() ?: 0
def pageTitle = "Battery Device Status (${devCount} device${devCount == 1 ? '' : 's'} selected)"
dynamicPage(name: "mainPage", title: pageTitle, uninstall: true, install: true) {
def devTitle = "Battery Device Selection"
if (showSectionDetails) devTitle += " (${devCount} selected)"
section(hideable:true, hidden:true, title: devTitle) {
input "devs", "capability.battery",
title: "Select devices (disabled devices will be omitted even if selected)",
submitOnChange:true, multiple:true, required:false
}
def noticeLabel = noticeSound ? (noticeSound instanceof Collection ? noticeSound*.displayName : [noticeSound.displayName]) : "Select sound notification device(s)"
def silentLabel = noticeSilent ? (noticeSilent instanceof Collection ? noticeSilent*.displayName : [noticeSilent.displayName]) : "Select silent notification device(s)"
def noticeTitle = "Notification Settings"
if (showSectionDetails) noticeTitle += " (${noticeLabel})"
section(hideable:true, hidden:true, title: noticeTitle) {
input "noticeSound", "capability.notification",
title: "Select Pushover device(s) for sound notifications (first report)",
submitOnChange:false, multiple:true, required:true
input "noticeSilent", "capability.notification",
title: "Select Pushover device(s) for silent notifications (remaining reports)",
submitOnChange:false, multiple:true, required:true
}
// Report Type, Scheduling & Logging
def batteryIntervalLabel = batteryIntervalHours ?: 24
def eventIntervalLabel = eventIntervalHours ?: 24
def activityIntervalLabel = activityIntervalHours ?: 24
def runTimeLabel = runTime ? timeToday(runTime, location.timeZone).format("hh:mm a") : ""
def loggingLabel = enableLogging ? "Enabled" : "Disabled"
def lowBattLabel = lowBatteryLevel ?: 80
def critBattLabel = criticalBatteryLevel ?: 60
def settingsTitle = "Report Type, Schedule & Logging"
if (showSectionDetails) {
settingsTitle += " (Daily Check: ${runTimeLabel}, Overdue Event Interval: ${eventIntervalLabel}h, Overdue Battery Interval: ${batteryIntervalLabel}h, " +
"Overdue Activity Interval: ${activityIntervalLabel}h, Battery Low = ${lowBattLabel}%, Battery Critical = ${critBattLabel}%, Debug Logging: ${loggingLabel})"
}
section(hideable:true, hidden:true, title: settingsTitle) {
def reportOptions = [
"offline":"Offline Devices",
"low":"Low Battery Devices",
"battery":"Last Battery Event",
"any":"Last Event (any type)",
"activity":"Last Activity"
]
def selected = reportTables ?: []
if (selected.contains("all")) {
selected = reportOptions.keySet() as List
app.updateSetting("reportTables", [value: selected, type: "enum"])
}
input "reportTables", "enum",
title:"Select which report tables to generate",
options:["all":"Select All Reports"] + reportOptions,
multiple:true,
value: selected,
submitOnChange:true,
required:true
input name:"runTime", type:"time", title:"Daily check time", required:true
input "batteryIntervalHours", "number", title:"Overdue battery event interval in hours (default = 24)", defaultValue:24, required:true
input "eventIntervalHours", "number", title:"Overdue event interval in hours (default = 24)", defaultValue:24, required:true
input "activityIntervalHours", "number", title:"Overdue activity interval in hours (default = 24)", defaultValue:24, required:true
input "lowBatteryLevel", "number", title:"Low battery warning level (%)", defaultValue:80, required:true
input "criticalBatteryLevel", "number", title:"Critically low battery level (%)", defaultValue:60, required:true
input "showSectionDetails", "bool", title:"Show extra details in section headers?", defaultValue:true
input "enableLogging", "bool", title:"Enable debug logging?", defaultValue:false
input "includeNeverRecent", "bool",
title:"Include devices with 'Never' battery event but recent activity?",
description:"If enabled, devices with no battery event but recent activity (within Overdue Activity Interval) will still be shown.",
defaultValue:false
}
// Sort Options
section(hideable:true, hidden:true, title:"Sort Options") {
// Offline
paragraph("<b>Offline Devices</b>")
input "sortBy_offline", "enum", title:"Sort by", options:["displayName":"Device Name"], defaultValue:"displayName", submitOnChange:true
input "sortOrder_offline", "enum", title:"Order", options:["asc":"Ascending","desc":"Descending"], defaultValue:"asc", submitOnChange:true
paragraph("<hr>")
// Low
paragraph("<b>Low Battery Devices</b>")
input "sortBy_low", "enum", title:"Sort by", options:["level":"Battery %","displayName":"Device Name"], defaultValue:"level", submitOnChange:true
input "sortOrder_low", "enum", title:"Order", options:["asc":"Ascending","desc":"Descending"], defaultValue:"asc", submitOnChange:true
paragraph("<hr>")
// Battery
paragraph("<b>Last Battery Event</b>")
input "sortBy_battery", "enum", title:"Sort by", options:["lastStr":"Last Event Time","displayName":"Device Name"], defaultValue:"lastStr", submitOnChange:true
input "sortOrder_battery", "enum", title:"Order", options:["asc":"Ascending","desc":"Descending"], defaultValue:"desc", submitOnChange:true
paragraph("<hr>")
// Any
paragraph("<b>Last Event (any type)</b>")
input "sortBy_any", "enum", title:"Sort by", options:["lastStr":"Last Event Time","displayName":"Device Name","lastEventStr":"Event Description"], defaultValue:"lastStr", submitOnChange:true
input "sortOrder_any", "enum", title:"Order", options:["asc":"Ascending","desc":"Descending"], defaultValue:"desc", submitOnChange:true
paragraph("<hr>")
// Activity
paragraph("<b>Last Activity</b>")
input "sortBy_activity", "enum", title:"Sort by", options:["lastActivity":"Last Activity Time","displayName":"Device Name","level":"Battery %"], defaultValue:"lastActivity", submitOnChange:true
input "sortOrder_activity", "enum", title:"Order", options:["asc":"Ascending","desc":"Descending"], defaultValue:"desc", submitOnChange:true
paragraph("<hr>")
}
// refresh Now + auto-run on load
section(title:"") {
input "refresh","button", title:"Refresh Table"
input "sendNow","button", title:"Send Report Now"
paragraph handler(sendNotifications=false) // show tables only
}
}
}
// Lifecycle
def installed() { initialize() }
def updated() {
unschedule()
unsubscribe()
initialize()
if (enableLogging) log.debug "Battery Device Status updated with ${devs?.size() ?: 0} devices"
}
void initialize() {
unschedule()
if (enableLogging) log.debug "Battery Device Status initializing ..."
if (runTime) schedule(runTime, handlerX)
log.info "Battery Device Status initialized with ${devs?.size() ?: 0} devices"
}
void handlerX() {
state.lastRun = new Date().format("yyyy-MM-dd hh:mm a", location.timeZone)
handler(sendNotifications=true)
}
def appButtonHandler(btn) {
switch (btn) {
case "refresh":
if (enableLogging) log.debug "Manual refresh requested"
handler(sendNotifications=false)
break
case "sendNow":
if (enableLogging) log.debug "Immediate report send requested"
handler(sendNotifications=true)
break
default:
if (enableLogging) log.debug "Unknown button: ${btn}"
break
}
}
String handler(sendNotifications=false) {
state.lastRun = new Date().format("yyyy-MM-dd hh:mm a", location.timeZone)
def htmlOut = ""
def htmlOrder = ["offline","low","battery","any","activity"]
def allReportTypes = ["activity","any","battery","low","offline"]
def selectedReports = reportTables ?: allReportTypes
def reportLabels = [
"battery":"Last Battery Event",
"any":"Last Event (any type)",
"offline":"Offline Devices",
"low":"Low Battery Devices",
"activity":"Last Activity"
]
// --- Generate all reports ---
def results = selectedReports.collectEntries { type ->
def report = (type == "low") ? generateLowBatteryTable(sendNotifications) :
(type == "activity") ? generateActivityTable(sendNotifications) :
generateReport(type, sendNotifications)
[(type): [label: reportLabels[type], html: report.html, plain: report.plain]]
}
// --- Build HTML for display ---
htmlOrder.each { type ->
def res = results[type]
if (res) htmlOut += "<h3>${res.label}</h3>${res.html}<hr><br>"
}
if (!sendNotifications && state.lastRun) {
htmlOut += "<br><small><i>Last run: ${state.lastRun}</i></small>"
}
// --- Notification sending ---
if (sendNotifications) {
// Ensure devices are arrays
def soundDevices = noticeSound ? (noticeSound instanceof Collection ? noticeSound : [noticeSound]) : []
def silentDevices = noticeSilent ? (noticeSilent instanceof Collection ? noticeSilent : [noticeSilent]) : []
// Build notification queue
def queue = []
allReportTypes.eachWithIndex { type, idx ->
def res = results[type]
if (!res?.plain?.trim()) return
def targets = (idx == 0) ? soundDevices : silentDevices
if (!targets) return
targets.each { dev ->
queue << [deviceId: dev.id, msg: "=== ${res.label} ===\n${res.plain.trim()}"]
}
}
// Schedule each message with a 5-second stagger
queue.eachWithIndex { item, idx ->
runIn(idx * 5, "sendDelayedNotification", [
overwrite: false,
data: item
])
}
}
return htmlOut
}
// --- Helper for asynchronous notification sending ---
void sendDelayedNotification(Map data) {
def deviceId = data.deviceId
def msg = data.msg
if (!deviceId || !msg) return
// Look up device dynamically
def device = (noticeSound instanceof Collection ? noticeSound : [noticeSound]).find { it.id == deviceId } ?:
(noticeSilent instanceof Collection ? noticeSilent : [noticeSilent]).find { it.id == deviceId }
if (!device) {
log.warn "Device not found for ID ${deviceId}"
return
}
try {
device.deviceNotification(msg)
if (enableLogging) log.debug "Sent notification to ${device.displayName}"
} catch (e) {
log.error "Failed to notify ${device.displayName}: ${e}"
}
}
// Report Generator (last event/battery/offline/any)
private Map generateReport(String type, boolean noteMode) {
def sortByVal = settings["sortBy_${type}"] ?: ((type=="offline") ? "displayName" : "lastStr")
def sortOrderVal = settings["sortOrder_${type}"] ?: "asc"
def selectedEnabledDevices = (devs ?: []).findAll{ !it.isDisabled() }
if (!selectedEnabledDevices) return [label:type, html:"No battery devices found.", plain:"No battery devices found."]
// Normalize includeNeverRecent setting to a proper boolean (handles "true"/"false" strings)
def includeNeverRecentFlag = (settings["includeNeverRecent"]?.toString()?.toLowerCase() == "true")
def rightNow = new Date()
def reportList = selectedEnabledDevices.collect { dev ->
// ? Fixed: "battery" type looks for battery events, "any" looks for any event
def lastEvent
if (type == "battery") {
lastEvent = dev.events(max:50)?.find{ it.name == "battery" }
} else if (type == "any") {
lastEvent = dev.events(max:1)?.find{ true }
} else if (type == "offline") {
lastEvent = dev.events(max:50)?.find{ it.name == "battery" }
}
def lastEventDate = lastEvent?.date
def eventDesc = (type == "any" && lastEvent) ? "(Event: ${lastEvent.name} ${lastEvent.value})" : ""
def fs = '\u2007'
def batteryLevel = dev.currentBattery != null ? Math.round(dev.currentBattery).toString().padLeft(3, fs) : "N/A".padLeft(3, fs)
def devStatus = dev.getStatus()?.toUpperCase()
def isOffline = devStatus in ["OFFLINE","INACTIVE","NOT PRESENT"] ||
(dev.currentHealthStatus?.toLowerCase() == "offline")
def needsNotice = false
if (type == "offline") {
needsNotice = isOffline
} else if (type == "battery") {
if (lastEventDate != null) { // overdue battery event
def batteryThresholdHours = (settings["batteryIntervalHours"] ?: 24) as int
needsNotice = ((rightNow.time - lastEventDate.time)/60000) > (batteryThresholdHours * 60)
}
// if lastEventDate == null -> needsNotice stays false (handled later)
} else if (type == "any") { // traditional behavior for "any"
needsNotice = !lastEventDate || ((rightNow.time - lastEventDate.time)/60000) > ((eventIntervalHours ?: 24)*60)
}
String lastStrUI = lastEventDate ? lastEventDate.format("yyyy-MM-dd hh:mm a", location.timeZone)
: "<span style='color:red;'>[Never]</span>"
String lastStrNote = lastEventDate ? lastEventDate.format("yyyy-MM-dd hh:mm a", location.timeZone)
: "0000-00-00 00:00 xx"
def row = [
device:dev,
lastDate:lastEventDate,
lastStrUI:lastStrUI,
lastStrNote:lastStrNote,
lastEventStr:eventDesc,
level:batteryLevel,
offline:isOffline,
needs:needsNotice
]
if (enableLogging) {
log.debug "DBG-${type?.toUpperCase()}: device='${dev.displayName}' | lastDate=${lastEventDate} | offline=${isOffline} | needs=${needsNotice}"
}
return row
}.findAll { it ->
// --- OFFLINE report: status-only, never include just because lastDate is null ---
if (type == "offline") {
// Only report as offline if the device is actually OFFLINE/INACTIVE/NOT PRESENT
// (or healthStatus says offline)
return (it.offline == true) // or: return (it.needs == true)
}
// Case 1: device has a valid last event and is overdue (needsNotice)
if (it.lastDate != null && it.needs) return true
// Case 2: device has NEVER reported (lastDate == null)
if (it.lastDate == null) {
// (you compute lastAct/recentAct but currently don't use recentAct to decide;
// leaving your existing behavior intact)
def lastAct = it.device.getLastActivity()
def nowMs = new Date().time
def thresholdMs = ((activityIntervalHours ?: 24) * 60 * 60 * 1000)
def recentAct = lastAct && ((nowMs - lastAct.time) <= thresholdMs)
if (includeNeverRecentFlag) {
return true // show all [Never] devices (for battery/any reports)
} else {
return false // exclude all [Never] devices
}
}
return false
}
// Sorting for generateReport
def neverList = reportList.findAll { it.lastDate == null }
def normalList = reportList.findAll { it.lastDate != null }
neverList = neverList.sort { it.device.displayName.toLowerCase() }
normalList = normalList.sort { it ->
switch(sortByVal) {
case "displayName": return it.device.displayName.toLowerCase()
case "lastStr": return it.lastDate
case "level": return (it.level.toString().isNumber() ? it.level.toInteger() : -1)
case "lastEventStr":return it.lastEventStr?.toLowerCase() ?: ""
default: return it.lastDate
}
}
if (sortOrderVal == "desc") normalList = normalList.reverse()
reportList = neverList + normalList
def totalChecked = selectedEnabledDevices.size()
def notReportedCount = reportList.size()
// Update header text based on report type
def eventThresholdHours = (settings["eventIntervalHours"] ?: 24) as int
def batteryThresholdHours = (settings["batteryIntervalHours"] ?: 24) as int
def headerText
if (type == "offline") {
headerText = (notReportedCount == 0) ?
"No devices report as being \"OFFLINE\" or \"INACTIVE\" or \"NOT PRESENT\".\n\n" :
"${notReportedCount} of ${totalChecked} selected devices report as being \"OFFLINE\" or \"INACTIVE\" or \"NOT PRESENT\".\n\n"
} else if (type == "battery") {
headerText = notReportedCount > 0 ?
(includeNeverRecentFlag ?
"${notReportedCount} of ${totalChecked} selected devices did not report a \"last battery event\" within ${batteryThresholdHours}h.\n\n" :
"${notReportedCount} of ${totalChecked} selected devices did not report a \"last battery event\" within ${batteryThresholdHours}h (table excludes devices with a \"Never\" last battery event).\n\n"
) :
"${totalChecked!=1?'All':'The'} ${totalChecked} selected device${totalChecked!=1?'s':''} reported battery events within ${batteryThresholdHours}h."
} else if (type == "any") {
headerText = notReportedCount > 0 ?
"${notReportedCount} of ${totalChecked} selected devices did not report any event within ${eventThresholdHours}h.\n\n" :
"${totalChecked!=1?'All':'The'} ${totalChecked} selected device${totalChecked!=1?'s':''} reported events within ${eventThresholdHours}h."
}
def headerHtml = headerText.replace("\n\n","<br><br>")
def tableHtml = ""
if (notReportedCount>0) {
tableHtml = "<table border='1' cellpadding='4' cellspacing='0' style='border-collapse:collapse;width:100%'><thead><tr>"
tableHtml += "<th>Last Event Time</th><th>Battery %</th><th>Device Name</th>"
if (type=="any") tableHtml += "<th>Event Description</th>"
tableHtml += "</tr></thead><tbody>"
reportList.each { it ->
tableHtml += "<tr>"
tableHtml += "<td>${it.lastStrUI}</td><td>${it.level}%</td><td><a href='/device/edit/${it.device.id}' target='_blank'>${it.device.displayName}</a></td>"
if (type=="any") tableHtml += "<td>${it.lastEventStr}</td>"
tableHtml += "</tr>"
}
tableHtml += "</tbody></table>"
}
def rows = []
if (notReportedCount>0) {
def header = "Last Event Time Battery % Device Name"
if (type=="any") header += " Event Description"
rows << header
rows << "-" * header.size()
reportList.each { it ->
def row = "${it.lastStrNote} ${it.level.toString()}% ${it.device}"
if (type=="any") row += " ${it.lastEventStr}"
rows << row
}
}
def plainMsg = headerText + (rows ? "\n" + rows.join("\n") : "")
return [label:type, html: headerHtml + tableHtml, plain: plainMsg]
}
// Low Battery Table
private Map generateLowBatteryTable(boolean noteMode) {
def selectedEnabledDevices = (devs ?: []).findAll{ !it.isDisabled() }
if (!selectedEnabledDevices) return [label:"Low Battery", html:"No battery devices found.", plain:"No battery devices found."]
def lowList = selectedEnabledDevices.collect { dev ->
def level = dev.currentBattery
if (level != null && level <= lowBatteryLevel) {
def critical = (level <= criticalBatteryLevel)
[device:dev, level:level, critical:critical]
}
}.findAll{ it != null }
if (!lowList) return [label:"Low Battery", html:"No low battery devices.", plain:"No low battery devices."]
def sortByVal = settings["sortBy_low"] ?: "level"
def sortOrderVal = settings["sortOrder_low"] ?: "asc"
def normName = { row -> (row?.device?.displayName ?: "").toLowerCase() }
lowList = lowList.sort { a, b ->
if (sortByVal == "displayName") {
// Device name sort (respects asc/desc)
int c = normName(a) <=> normName(b)
return (sortOrderVal == "desc") ? -c : c
}
// sortByVal == "level" (Battery %)
int lvlA = (a?.level instanceof Number) ? a.level as int : -1
int lvlB = (b?.level instanceof Number) ? b.level as int : -1
if (sortOrderVal == "desc") {
// Primary: level DESC
int c = lvlB <=> lvlA
if (c != 0) return c
// Secondary: name ASC within each level
return normName(a) <=> normName(b)
} else {
// Primary: level ASC
int c = lvlA <=> lvlB
if (c != 0) return c
// Secondary: name ASC within each level
return normName(a) <=> normName(b)
}
}
def totalChecked = selectedEnabledDevices.size()
def lowCount = lowList.size()
def summaryText = "${lowCount} of ${totalChecked} selected devices report having low battery levels."
def tableHtml = "${summaryText}<br><br>"
tableHtml += "<table style='border-collapse:collapse;width:100%;border:1px solid black;' cellpadding='4' cellspacing='0'><thead><tr>"
tableHtml += "<th style='border:1px solid black;'>Battery %</th><th style='border:1px solid black;'>Device Name</th></tr></thead><tbody>"
lowList.each { it ->
def color = it.level <= criticalBatteryLevel ? "red" : "black"
tableHtml += "<tr>"
tableHtml += "<td style='color:${color};border:1px solid black;'>${it.level}%</td>"
tableHtml += "<td style='border:1px solid black;'><a href='/device/edit/${it.device.id}' target='_blank'>${it.device.displayName}</a></td>"
tableHtml += "</tr>"
}
tableHtml += "</tbody></table>"
def plainRows = lowList.collect { "${it.level}% ${it.device}" }
def plainMsg = "${summaryText}\n\n" + plainRows.join("\n")
return [label:"Low Battery", html: tableHtml, plain: plainMsg]
}
// Last Activity Table
private Map generateActivityTable(boolean noteMode) {
def sortByVal = settings["sortBy_activity"] ?: "lastActivity"
def sortOrderVal = settings["sortOrder_activity"] ?: "desc"
def activityThresholdHours = (settings["activityIntervalHours"] ?: 24) as int
def thresholdMillis = activityThresholdHours * 60 * 60 * 1000
def selectedEnabledDevices = (devs ?: []).findAll{ !it.isDisabled() }
if (!selectedEnabledDevices) return [label:"Last Activity", html:"No battery devices found.", plain:"No battery devices found."]
def rightNow = new Date()
def reportList = selectedEnabledDevices.collect { dev ->
def lastActivity = dev.getLastActivity()
def overdue = lastActivity ? (rightNow.time - lastActivity.time > thresholdMillis) : true
String lastStrUI
String lastStrNote
if (lastActivity) {
def formatted = lastActivity.format("yyyy-MM-dd hh:mm a", location.timeZone)
lastStrUI = "<span style='color:red;'>${formatted}</span>"
lastStrNote = formatted
} else {
lastStrUI = "<span style='color:red;'>[Never]</span>"
lastStrNote = "0000-00-00 00:00 xx"
}
def fs = '\u2007'
def batteryLevel = dev.currentBattery != null ? Math.round(dev.currentBattery).toString().padLeft(3, fs) : "N/A".padLeft(3, fs)
[device:dev,lastActivity:lastActivity,lastStrUI:lastStrUI,lastStrNote:lastStrNote,level:batteryLevel,overdue:overdue]
}.findAll { it.overdue } // ? only keep overdue devices
// Sorting for generateActivityTable
def neverList = reportList.findAll { it.lastActivity == null }
def normalList = reportList.findAll { it.lastActivity != null }
// Sort "Never" group alphabetically by device name (always asc)
neverList = neverList.sort { it.device.displayName.toLowerCase() }
// Sort normal group by selected sort key
normalList = normalList.sort { it ->
switch(sortByVal) {
case "displayName": return it.device.displayName.toLowerCase()
case "lastActivity":return it.lastActivity
case "level": return (it.level.toString().isNumber() ? it.level.toInteger() : -1)
default: return it.lastActivity
}
}
if (sortOrderVal == "desc") normalList = normalList.reverse()
// Combine: [Never] group always first
reportList = neverList + normalList
def totalChecked = selectedEnabledDevices.size()
def overdueCount = reportList.size()
def summaryText = (overdueCount > 0) ?
"${overdueCount} of ${totalChecked} selected devices have overdue activity." :
"${totalChecked!=1?'All':'The'} ${totalChecked} selected device${totalChecked!=1?'s':''} reported activity within ${activityThresholdHours}h."
// Build HTML table
def tableHtml = "${summaryText}<br><br>"
if (overdueCount > 0) {
tableHtml += "<table style='border-collapse:collapse;width:100%;border:1px solid black;' cellpadding='4' cellspacing='0'><thead><tr>"
tableHtml += "<th style='border:1px solid black;'>Last Activity</th><th style='border:1px solid black;'>Battery %</th><th style='border:1px solid black;'>Device Name</th></tr></thead><tbody>"
reportList.each { it ->
tableHtml += "<tr>"
tableHtml += "<td style='border:1px solid black;'>${it.lastStrUI}</td>"
tableHtml += "<td style='border:1px solid black;'>${it.level}%</td>"
tableHtml += "<td style='border:1px solid black;'><a href='/device/edit/${it.device.id}' target='_blank'>${it.device.displayName}</a></td>"
tableHtml += "</tr>"
}
tableHtml += "</tbody></table>"
}
// Plain text version
def plainRows = []
if (overdueCount > 0) {
plainRows = reportList.collect { row ->
//"?? ${row.lastStrNote} ${row.level}% ${row.device}"
"${row.lastStrNote} ${row.level}% ${row.device}"
}
}
def plainMsg = "${summaryText}\n\n" + (plainRows ? plainRows.join("\n") : "")
return [label:"Last Activity", html: tableHtml, plain: plainMsg]
}
// Shared sort helper to keep "[Never]" grouped first and alphabetically
private List sortWithNeverGrouping(List reportList, String sortByVal, String sortOrderVal, boolean activityMode=false) {
def sorted = reportList.sort { it ->
def never = activityMode ? (it.lastActivity == null) : (it.lastDate == null)
def tier = never ? 0 : 1
def secondary = never ? it.device.displayName.toLowerCase() : null
def sortKey
switch(sortByVal) {
case "displayName": sortKey = it.device.displayName.toLowerCase(); break
case "lastActivity":
sortKey = it.lastActivity ?: new Date(0)
break
case "lastStr":
sortKey = it.lastDate ?: new Date(0)
break
case "level":
sortKey = (it.level.toString().isNumber() ? it.level.toInteger() : -1)
break
case "lastEventStr":
sortKey = it.lastEventStr?.toLowerCase() ?: ""
break
default:
sortKey = activityMode ? (it.lastActivity ?: new Date(0)) : (it.lastDate ?: new Date(0))
}
return [tier, secondary, sortKey]
}
if (sortOrderVal == "desc") sorted = sorted.reverse()
return sorted
}

