Battery Device Status 1.17 (2026-01-04)

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
}

9 Likes

Looks like my curtain isn't closing tonight!

1 Like

Is that a real battery state for your curtain bot? I've found that some devices (including one mains powered device that has a battery backup, so it shows up on my list of battery powered devices) don't report battery level very accurately.

Yeah, unfortunately it's real. I have two of the Zemismart curtain motors and one has a flaky battery which drops twice as fast as the other one. It is on my radar but I just leave it until the Mrs complains before I react!

1 Like

I am WAF prompt-driven myself.