Battery Device Status 1.4 2025-10-24

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.
  • 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.

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.

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.
*/

definition(
    name: "Battery Device Status 1.14",
    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() {
    dynamicPage(name: "mainPage", title: "Battery Device Status", uninstall: true, install: true) {

        // Device selection
        def devCount = devs?.findAll { !it.isDisabled() }?.size() ?: 0
        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
        }

        // Notification devices
        def noticeNames = notice ? ((notice instanceof Collection) ? notice*.displayName : [notice.displayName]) : []
        def noticeLabel = noticeNames ? noticeNames.join(", ") : "Select notification device(s)"
        def noticeTitle = "Notification Settings"
        if (showSectionDetails) noticeTitle += " (${noticeLabel})"
        section(hideable:true, hidden:true, title: noticeTitle) {
            input "notice", "capability.notification",
                title:"Select notification device(s)",
                submitOnChange:true, multiple:true, required:true
        }

        // Report Type, Scheduling & Logging
        def reportIntervalLabel = reportIntervalHours ?: 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: ${reportIntervalLabel}h, Overdue Activity Interval: ${activityIntervalLabel}h, Battery Low = ${lowBattLabel}%, Battery Critical = ${critBattLabel}%, Debug Logging: ${loggingLabel})"
        }

        section(hideable:true, hidden:true, title: settingsTitle) {
        //section(hideable:true, title: settingsTitle) {
            // Report options
            def reportOptions = [
                "offline":"Offline Devices",
                "low":"Low Battery Devices",
                "battery":"Last Battery Event",
                "any":"Last Event (any type)",
                "activity":"Last Activity"
            ]

            def selected = reportTables ?: []
            def expanded = selected

            // Handle "Select All Reports"
            if (selected.contains("all")) {
                expanded = reportOptions.keySet() as List
                app.updateSetting("reportTables", [value: expanded, type: "enum"])
            }

            input "reportTables", "enum",
                title:"Select which report tables to generate",
                options:["all":"Select All Reports"] + reportOptions,
                multiple:true,
                value: expanded,
                submitOnChange:true,
                required:true

            input name:"runTime", type:"time", title:"Daily check time", required:true
            input "reportIntervalHours", "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
        }

        // Sort Options
        section(hideable:true, hidden:true, title:"Sort Options") {
        //section(hideable:true, title:"Sort Options") {
            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>")

            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>")

            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>")

            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>")

            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)
    def noticeNames = notice ? ((notice instanceof Collection) ? notice*.displayName : [notice.displayName]) : []
    log.info "Battery Device Status scheduled check running at ${state.lastRun}, notifying: ${noticeNames.join(', ')}"
    handler(sendNotifications=true)
}

def appButtonHandler(btn) {
	switch (btn) {
		case "refresh":
			if (enableLogging) log.debug "Manual refresh requested"
			handler(sendNotifications=false)   // Show only
			break
		case "sendNow":
			if (enableLogging) log.debug "Immediate report send requested"
			handler(sendNotifications=true)     // Send notifications now
			break
		default:
			if (enableLogging) log.debug "Unknown button: ${btn}"
			break
	}
}

// Main Handler
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 = ["battery","any","offline","low","activity"]
    def selectedReports = reportTables ?: allReportTypes  // never contains 'all'

    def reportLabels = [
        "battery":"Last Battery Event",
        "any":"Last Event (any type)",
        "offline":"Offline Devices",
        "low":"Low Battery Devices",
        "activity":"Last Activity"
    ]

    def results = []
    selectedReports.each { type ->
        def result = (type == "low") ? generateLowBatteryTable(sendNotifications) :
                     (type == "activity") ? generateActivityTable(sendNotifications) :
                     generateReport(type, sendNotifications)
        results << [type:type, label:reportLabels[type], html:result.html, plain:result.plain]
    }

    htmlOrder.each { orderedType ->
        def res = results.find { it.type == orderedType }
        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>"
    }

    if (sendNotifications) {
        def devices = notice instanceof Collection ? notice : (notice ? [notice] : [])
        def plainOrder = htmlOrder.reverse()   // <<< reverse the HTML order

		// Send notifications (both scheduled and "Send Report Now") with staggered timing
		plainOrder.eachWithIndex { orderedType, idx ->
			def res = results.find { it.type == orderedType }
			if (!res) return
			def msg = res.plain?.trim() ?: "${res.label}: No issues found at ${state.lastRun}."
			msg = "=== ${res.label} ===\n${msg}"

			// Stagger each message by 5 seconds to preserve order
			runIn(idx * 5, "sendDelayedNotification", [overwrite: false, data:[msg: msg, deviceIds: devices*.id]])
		}
    }

    return htmlOut
}

// Helper to send delayed notifications
void sendDelayedNotification(Map data) {
    def msg = data.msg
    def deviceIds = data.deviceIds ?: []
    def devices = deviceIds.collect { id -> notice.find { it.id == id } }.findAll { it }
    devices.each { n ->
        try {
            n.deviceNotification(msg)
        } catch (e) {
            log.error "Failed to notify ${n}: ${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."]

    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") {
            // For "battery" type, find last battery event
            lastEvent = dev.events(max:50)?.find{ it.name == "battery" }
        } else if (type == "any") {
            // For "any" type, find the most recent event of any type
            lastEvent = dev.events(max:1)?.find{ true }
        } else if (type == "offline") {
            // For offline, we still check battery events for the timestamp
            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 = (type == "offline" ? isOffline : !lastEventDate || ((rightNow.time - lastEventDate.time)/60000) > ((reportIntervalHours ?: 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"
        [device:dev,lastDate:lastEventDate,lastStrUI:lastStrUI,lastStrNote:lastStrNote,lastEventStr:eventDesc,level:batteryLevel,offline:isOffline,needs:needsNotice]
    }.findAll{ it.needs }

    // Sorting for generateReport
    def neverList  = reportList.findAll { it.lastDate == null }
    def normalList = reportList.findAll { it.lastDate != 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 "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()

    // Combine: [Never] group always first
    reportList = neverList + normalList

    def totalChecked = selectedEnabledDevices.size()
    def notReportedCount = reportList.size()

    // ? Fixed: Update header text based on report type
    def eventThresholdHours = (settings["reportIntervalHours"] ?: 24) as int
    def headerText
    if (type == "offline") {
        headerText = "${notReportedCount} of ${totalChecked} selected devices report as being \"OFFLINE\".\n\n"
    } else if (type == "battery") {
        headerText = notReportedCount > 0 ?
            "${notReportedCount} of ${totalChecked} selected devices did not report a \"last battery event\".\n\n" :
            "${totalChecked!=1?'All':'The'} ${totalChecked} selected device${totalChecked!=1?'s':''} reported battery events within ${eventThresholdHours}h."
    } else if (type == "any") {
        headerText = notReportedCount > 0 ?
            "${notReportedCount} of ${totalChecked} selected devices did not report any event.\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"

    lowList = lowList.sort { it ->
        switch(sortByVal) {
            case "displayName": return it.device.displayName.toLowerCase()
            case "level": return it.level
            default: return it.level
        }
    }
    if (sortOrderVal == "desc") lowList = lowList.reverse()

    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 thresholdHours = (settings["activityIntervalHours"] ?: 24) as int
    def thresholdMillis = thresholdHours * 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 ${thresholdHours}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.