[Release] Battery Monitor 2.0

yes please!

Update the code with new version from the OP.

I have updated it. I think I will start adding .1, .2 etc so members know

I seen the single item behavior when I changed the time report after setting it up.

I have since made changes to correct that behavior.

2 Likes

+1 for adding a screen report — either automatically or along with some way to trigger it.

I tried the automatic discovery, and no devices were found. If I select devices I do get a report.

How does the batter drain tracking work? I'm only seeing the current charge but no delta. That sounds like a really useful feature.

I will work more on the battery drain feature.
I am tracking this thread but I’m currently out of country for work event. Will return Monday. I will have some time off and on to make corrections for testing on my side.

Current behavioral issues:
1 - auto discovery
2- battery drain tracking

Current Additions requested:
1- refresh update on current stats

  • Add refresh button to push notification or show current stats.
4 Likes

2.3.1 Released

Still working through the Battery Summary report on demand.
See OP

1 Like

Thanks for the update.

I installed the new version, changed the daily report time, hit Done. Report ran when expected and now comes as a single message. Nicely done. :slight_smile:

2 Likes

@CuriousB This app has been started and will be created under a new post. I’m working through errors and this one will take some time. Once it’s ready. I will let you know. Send me a PM and I will share the code with you as I go. Sometimes two sets of eyes is better than one when testing.

Version 2.8.1 released. There is a gap in versions because I'm running tests on each version.

See Notes for 2.8.1 in OP.

Multiple Changes made.

Once everything gets ironed out and updated, i will revert the naming back to 2.0 for clarity in the apps for HE.

1 Like

Version 2.8.2 Released with input received from members.
See OP

re Version 2.8.2:

  • When I select "Auto discover battery devices", then "Run Summary Now" reports "Battery Report for 0 devices". I have to manually select all of the battery devices to get actual battery state.
  • Please put the "Manual device selection" control into a collapsible section -- it takes up a huge amount of screen space but is rarely updated.

Attached is an app I wrote (with AI help) showing examples of collapsible sections as well as "main screen" tables with data in columns.

/*
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").
    * Clickable table headers for visual sorting (notifications use settings-based sort)
    * Yellow headers with default sort indicators
*/

definition(
    name: "Battery Device Status 1.18",
    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") {
            paragraph("<i><b>Note:</b> These settings control the default sort order for tables AND the sort order used in notifications. You can also click any table header to re-sort the display temporarily.</i>")
            paragraph("<hr>")
            
            // 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 Battery 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) :
                     (type == "activity") ? generateActivityTable(sendNotifications, type) :
                     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>"
    }
    
    // --- Add JavaScript for table sorting ---
    if (!sendNotifications) {
        htmlOut += """
        <script>
        function sortBatteryTable(tableId, columnIndex) {
            const table = document.getElementById(tableId);
            if (!table) return;
            
            const tbody = table.querySelector('tbody');
            if (!tbody) return;
            
            const rows = Array.from(tbody.querySelectorAll('tr'));
            const headers = table.querySelectorAll('th');
            
            // Initialize sort directions object if it doesn't exist
            if (!window.batteryTableSorts) {
                window.batteryTableSorts = {};
            }
            if (!window.batteryTableSorts[tableId]) {
                window.batteryTableSorts[tableId] = {};
            }
            
            // Determine sort direction
            const currentDirection = window.batteryTableSorts[tableId][columnIndex] || 'asc';
            const newDirection = currentDirection === 'asc' ? 'desc' : 'asc';
            window.batteryTableSorts[tableId][columnIndex] = newDirection;
            
            // Remove sort indicators from all headers
            headers.forEach(header => {
                header.classList.remove('sort-asc', 'sort-desc');
                header.style.position = 'relative';
            });
            
            // Add sort indicator to current header
            headers[columnIndex].classList.add('sort-' + newDirection);
            
            // Sort rows
            rows.sort((a, b) => {
                const aCell = a.querySelectorAll('td')[columnIndex];
                const bCell = b.querySelectorAll('td')[columnIndex];
                let aText = aCell ? aCell.textContent.trim() : '';
                let bText = bCell ? bCell.textContent.trim() : '';
                
                // Handle [Never] specially - always sort to end
                const aNever = aText.includes('[Never]');
                const bNever = bText.includes('[Never]');
                
                if (aNever && !bNever) return 1;
                if (!aNever && bNever) return -1;
                if (aNever && bNever) return 0;
                
                // Remove % signs for battery columns
                aText = aText.replace('%', '').trim();
                bText = bText.replace('%', '').trim();
                
                // Try to parse as numbers for numeric sorting
                const aNum = parseFloat(aText.replace(/[^0-9.-]/g, ''));
                const bNum = parseFloat(bText.replace(/[^0-9.-]/g, ''));
                
                let comparison = 0;
                
                if (!isNaN(aNum) && !isNaN(bNum)) {
                    // Numeric comparison
                    comparison = aNum - bNum;
                } else {
                    // String comparison (case-insensitive)
                    comparison = aText.toLowerCase().localeCompare(bText.toLowerCase());
                }
                
                return newDirection === 'asc' ? comparison : -comparison;
            });
            
            // Re-append sorted rows
            rows.forEach(row => tbody.appendChild(row));
        }
        </script>
        <style>
        .battery-table th {
            cursor: pointer;
            user-select: none;
            position: relative;
            background-color: #FFD700;
            color: #000;
            font-weight: bold;
        }
        .battery-table th:hover {
            background-color: #FFC700;
        }
        .battery-table th.sort-asc::after {
            content: ' ▲';
            font-size: 0.8em;
        }
        .battery-table th.sort-desc::after {
            content: ' ▼';
            font-size: 0.8em;
        }
        </style>
        """
    }

    // --- 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 a \"last battery event\" 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) {
        def tableId = "table_${type}"
        
        // Determine which column gets the default sort indicator
        def sortColIndex = 0
        if (sortByVal == "displayName") sortColIndex = 2
        else if (sortByVal == "lastStr") sortColIndex = 0
        else if (sortByVal == "level") sortColIndex = 1
        else if (sortByVal == "lastEventStr") sortColIndex = 3
        
        def sortClass = (sortOrderVal == "desc") ? "sort-desc" : "sort-asc"
        
        tableHtml = "<table id='${tableId}' class='battery-table' border='1' cellpadding='4' cellspacing='0' style='border-collapse:collapse;width:100%'><thead><tr>"
		def col1Header = (type == "battery") ? "Last Battery Event Time" : "Last Event Time"
		tableHtml += "<th onclick='sortBatteryTable(\"${tableId}\", 0)' class='${sortColIndex == 0 ? sortClass : ""}' style='width:210px;'>${col1Header}</th>"
        tableHtml += "<th onclick='sortBatteryTable(\"${tableId}\", 1)' class='${sortColIndex == 1 ? sortClass : ""}' style='width:100px;'>Battery %</th>"
        tableHtml += "<th onclick='sortBatteryTable(\"${tableId}\", 2)' class='${sortColIndex == 2 ? sortClass : ""}'>Device Name</th>"
        if (type=="any") tableHtml += "<th onclick='sortBatteryTable(\"${tableId}\", 3)' class='${sortColIndex == 3 ? sortClass : ""}'>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 col1HeaderPlain = (type == "battery") ? "Last Battery Event Time" : "Last Event Time"
		def header = "${col1HeaderPlain}            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, String type = "low") {
    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."

		// Determine which column gets the default sort indicator
		def sortColIndex = (sortByVal == "level") ? 0 : 1
		def sortClass = (sortOrderVal == "desc") ? "sort-desc" : "sort-asc"

		def tableId = "table_${type}"
		def tableHtml = "${summaryText}<br><br>"
		tableHtml += "<table id='${tableId}' class='battery-table' style='border-collapse:collapse;width:100%;border:1px solid black;' cellpadding='4' cellspacing='0'><thead><tr>"
		tableHtml += "<th onclick='sortBatteryTable(\"${tableId}\", 0)' class='${sortColIndex == 0 ? sortClass : ""}' style='border:1px solid black;width:100px;'>Battery %</th>"
		tableHtml += "<th onclick='sortBatteryTable(\"${tableId}\", 1)' class='${sortColIndex == 1 ? sortClass : ""}' style='border:1px solid black;'>Device Name</th>"
		tableHtml += "</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, String type = "activity") {
    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."

    // Determine which column gets the default sort indicator
    def sortColIndex = 0
    if (sortByVal == "displayName") sortColIndex = 2
    else if (sortByVal == "lastActivity") sortColIndex = 0
    else if (sortByVal == "level") sortColIndex = 1
    
    def sortClass = (sortOrderVal == "desc") ? "sort-desc" : "sort-asc"

    // Build HTML table
    def tableId = "table_${type}"
    def tableHtml = "${summaryText}<br><br>"
    if (overdueCount > 0) {
        tableHtml += "<table id='${tableId}' class='battery-table' style='border-collapse:collapse;width:100%;border:1px solid black;' cellpadding='4' cellspacing='0'><thead><tr>"
        tableHtml += "<th onclick='sortBatteryTable(\"${tableId}\", 0)' class='${sortColIndex == 0 ? sortClass : ""}' style='border:1px solid black;width:210px;'>Last Activity</th>"
        tableHtml += "<th onclick='sortBatteryTable(\"${tableId}\", 1)' class='${sortColIndex == 1 ? sortClass : ""}' style='border:1px solid black;width:100px;'>Battery %</th>"
        tableHtml += "<th onclick='sortBatteryTable(\"${tableId}\", 2)' class='${sortColIndex == 2 ? sortClass : ""}' style='border:1px solid black;'>Device Name</th>"
        tableHtml += "</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}"
        }
    }
    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
}

Auto discovery was intended to display battery operated devices from your full list of devices. This prevents you from having go through all of your devices and select the battery devices you want to monitor.

I do not monitor all of my own battery devices for specific reasons,
So I didn’t have it auto populate and check mark battery devices.
I would assume some would have certain devices they may not want to fully monitor.

I will look into this

Ah, understood now. Except that when auto discovery is OFF, the presented list of devices is only my battery devices, so there seems to be no difference whether that control is ON or OFF.

1 Like

Yeah. I will be correcting that with next update, or 2, in the next day or so.

Keep the info coming team. I think I have it close to finalized.

2 Likes

I really like the new reports, and love that I can see them in the browser.

I wonder if you would consider making the reports a grid or table so it's a bit easier to read? Some of my device names are sort, and some are rather long. The right-hand column ends up being hard to scan down.

1 Like

Appreciate your (and contributors) work on this.

As others mentioned when i intially installed 2.8, it didn't automatically find my devices. I manually added them in.

Installed 2.8.2 and the battery summary is the exact report I was looking for. I was even starting to investigate how to do a dashboard for this. I much prefer this overall summary over individual notifications for each device.

Agree of a suggested improvement in the table positions/formatting adjustment for readability.

@JdThomas24

The updates have been great!

Request. When doing reports on screen, can you do it column and row style (think z-wave details page) do that it's easier to read?

Thank you for the work!

2.8.3 Released - See OP with notes

Summary Tables now included

Auto Device detection changes coming in next update.

3 Likes

2.8.4 released.
See OP notes

3 Likes

Again, thank you for all the work. Can I suggest you also add this to Hubitat Package Manager? (Makes it easier for updating)

This is an incredibly useful app.

I will be looking into that process in the coming week.

I honestly never planned to add it to HPM but I’m rethinking that now.

5 Likes