/*
Battery Device Status
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.
* Scan time (total and per-report breakdown) displayed next to last run timestamp after each manual refresh.
* 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.
* The "lastBattery" date for IKEA Parasoll Door/Window Sensors is considered to be the same as a "Last Battery Event" date.
* 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
* Multi-hub support: Hubs #2 and #3 queried via Maker API; remote device names include hub label for identification.
* Optional toggle to exclude virtual devices from all reports (local and remote hubs).
* Manually excluded device IDs field for remote hubs allows permanent exclusion of specific devices (e.g., disabled devices not filterable via Maker API).
* App instance rename control and Reset to App Name button in Report Type, Schedule & Logging.
*/
definition(
name: "Battery Device Status 1.39",
namespace: "John Land",
author: "John Land via ChatGPT",
description: "Battery Device Status with battery %, offline/low battery reporting, last activity, configurable sort options, multi-hub support",
installOnOpen: true,
category: "Convenience",
iconUrl: "",
iconX2Url: "",
importUrl: "https://raw.githubusercontent.com/JohnFLand/Battery-Device-Status/refs/heads/main/Battery_Device_Status.groovy"
)
preferences {
page(name: "mainPage")
}
// ─────────────────────────────────────────────────────────────────────────────
// App instance label helpers
// ─────────────────────────────────────────────────────────────────────────────
private String getAppDisplayName() {
String configured = settings?.vAppLabel?.toString()?.trim()
if (configured) return configured
String currentLabel = null
try {
currentLabel = app?.label?.toString()?.trim()
} catch (Throwable ignored) { }
if (currentLabel) return currentLabel
return app?.name ?: "Battery Device Status"
}
private void syncAppInstanceLabel() {
String desired = settings?.vAppLabel?.toString()?.trim()
if (!desired) return
String currentLabel = null
try {
currentLabel = app?.label?.toString()?.trim()
} catch (Throwable ignored) { }
if (desired != currentLabel) {
try {
app.updateLabel(desired)
if (enableLogging) log.debug "Battery Device Status app label updated to '${desired}'"
} catch (Exception e) {
log.warn "Battery Device Status: app label update failed — ${e.message}"
}
}
}
private void resetAppInstanceLabel() {
String defaultName = app?.name?.toString() ?: "Battery Device Status"
try {
app.updateLabel(defaultName)
app.updateSetting("vAppLabel", [value: defaultName, type: "text"])
log.info "Battery Device Status: app label reset to app name '${defaultName}'"
} catch (Exception e) {
log.warn "Battery Device Status: app label reset failed — ${e.message}"
}
}
private String htmlEscape(Object val) {
String s = val == null ? "" : val.toString()
return s
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace("\"", """)
.replace("'", "'")
}
def mainPage() {
syncAppInstanceLabel()
def devCount = devs?.findAll { !it.isDisabled() }?.size() ?: 0
def hub1LabelVal = settings["hub1Label"] ?: (location.name ?: "Hub 1")
def totalDevCount = devCount +
([2, 3].sum { hubNum ->
settings["hub${hubNum}Enabled"] ? normalizeRemoteSelectionList(settings["hub${hubNum}SelectedDevices"]).size() : 0
} ?: 0)
def pageTitle = "${htmlEscape(getAppDisplayName())} (${totalDevCount} device${totalDevCount == 1 ? '' : 's'} selected)"
dynamicPage(name: "mainPage", title: pageTitle, uninstall: true, install: true) {
// ── Reports + Action Buttons (TOP) ───────────────────────────────────
section(title:"") {
input "refresh", "button", title:"Refresh Table"
input "sendNow", "button", title:"Send Report Now"
paragraph handler(sendNotifications=false)
input "refresh2", "button", title:"Refresh Table"
}
// ── Hub #1 – Local Device Selection ──────────────────────────────────
def devTitle = "Battery Device Selection for Hub #1 – ${hub1LabelVal}"
devTitle += devCount > 0 ? " — ${devCount} monitored" : " — No devices selected"
section(hideable:true, hidden:true, title: devTitle) {
input "hub1Label", "text",
title: "Friendly label for Hub #1 (shown in section header)",
defaultValue: (location.name ?: "Hub 1"), required: false, submitOnChange: true
input "devs", "capability.battery",
title: "Select devices (disabled devices will be omitted even if selected)",
submitOnChange:true, multiple:true, required:false
}
// ── Hub #2 – Remote ───────────────────────────────────────────────────
def hub2Enabled = settings["hub2Enabled"]
def hub2ActionVal = settings["hub2Action"]
def hub2ActionOpen = (hub2ActionVal && hub2ActionVal != "none")
def hub2LabelVal = settings["hub2Label"] ?: "Hub 2"
def hub2SelIds = normalizeRemoteSelectionList(settings["hub2SelectedDevices"])
def hub2Title = "Battery Device Selection for Hub #2 – ${hub2LabelVal}"
if (hub2Enabled) hub2Title += hub2SelIds.size() > 0 ? " — ${hub2SelIds.size()} monitored" : " — No devices selected"
section(hideable:true, hidden:!hub2ActionOpen, title:hub2Title) {
if (hub2ActionOpen) {
def hub2Stored = state["hub2BatteryDevices"] ?: []
switch (hub2ActionVal) {
case "load":
loadRemoteBatteryDeviceList(2, settings["hub2Ip"], settings["hub2AppId"], settings["hub2Token"]); break
case "selAll":
app.updateSetting("hub2SelectedDevices", [value: hub2Stored.collect { it.id.toString() }, type: "enum"]); break
case "unselAll":
app.updateSetting("hub2SelectedDevices", [value: [], type: "enum"]); break
}
app.updateSetting("hub2Action", [value: "none", type: "enum"])
}
input "hub2Enabled", "bool", title:"Enable Hub #2?", defaultValue:false, submitOnChange:true
if (hub2Enabled) {
input "hub2Label", "text", title:"Friendly label for Hub #2 (shown after device name in reports)",
defaultValue:"Hub 2", required:true, submitOnChange:true
input "hub2ShowConn", "bool",
title:"Show / Edit Connection Settings (IP, App ID, Token)",
defaultValue:true, submitOnChange:true
if (settings["hub2ShowConn"]) {
paragraph("Install Maker API on Hub #2, expose all desired battery devices, enter credentials below, then choose Load / Reload Device List from the Action dropdown.")
input "hub2Ip", "text", title:"Hub #2 IP address", required:true, submitOnChange:false
input "hub2AppId", "text", title:"Hub #2 Maker API app ID", required:true, submitOnChange:false
input "hub2Token", "text", title:"Hub #2 Maker API access token", required:true, submitOnChange:false
} else {
def sum = settings["hub2Ip"] ? "Connected to ${settings['hub2Ip']}" : "Not yet configured"
paragraph("Connection: ${sum}. Toggle above to edit.")
}
def hub2Status = state["hub2LoadStatus"]
if (hub2Status) {
def c = hub2Status.startsWith("OK") ? "green" : "red"
paragraph("Last load: ${hub2Status}")
}
input "hub2Action", "enum", title:"Hub #2 Actions", defaultValue:"none",
options:["none":"Choose action…",
"load":"⟳ Load / Reload Device List from Hub #2",
"selAll":"✓ Select All battery devices",
"unselAll":"✗ Clear all selected devices"],
required:false, submitOnChange:true
def hub2Opts = buildRemoteBatteryDeviceOptions(2)
if (hub2Opts == null) {
paragraph("Choose Load / Reload Device List above to fetch battery devices from Hub #2.")
} else if (hub2Opts.size() == 0) {
paragraph("No battery-capable devices returned by Hub #2 Maker API. Check that battery devices are selected in the Maker API app on Hub #2.")
} else {
input "hub2SelectedDevices", "enum",
title:"Battery devices to monitor on Hub #2 (${hub2Opts.size()} available)",
options:hub2Opts, multiple:true, required:false, submitOnChange:true
}
paragraph("Disabled devices: Hubitat's Maker API does not expose disabled state reliably, so disabled remote devices cannot be filtered automatically. If a disabled device appears in reports, deselect it above or enter its ID below to permanently exclude it. The device ID appears in its edit URL: /device/edit/169. Comma-separated.")
input "hub2ExcludeIds", "text",
title:"Hub #2: Manually excluded device IDs (comma-separated)",
required:false, submitOnChange:false
}
}
// ── Hub #3 – Remote ───────────────────────────────────────────────────
def hub3Enabled = settings["hub3Enabled"]
def hub3ActionVal = settings["hub3Action"]
def hub3ActionOpen = (hub3ActionVal && hub3ActionVal != "none")
def hub3LabelVal = settings["hub3Label"] ?: "Hub 3"
def hub3SelIds = normalizeRemoteSelectionList(settings["hub3SelectedDevices"])
def hub3Title = "Battery Device Selection for Hub #3 – ${hub3LabelVal}"
if (hub3Enabled) hub3Title += hub3SelIds.size() > 0 ? " — ${hub3SelIds.size()} monitored" : " — No devices selected"
section(hideable:true, hidden:!hub3ActionOpen, title:hub3Title) {
if (hub3ActionOpen) {
def hub3Stored = state["hub3BatteryDevices"] ?: []
switch (hub3ActionVal) {
case "load":
loadRemoteBatteryDeviceList(3, settings["hub3Ip"], settings["hub3AppId"], settings["hub3Token"]); break
case "selAll":
app.updateSetting("hub3SelectedDevices", [value: hub3Stored.collect { it.id.toString() }, type: "enum"]); break
case "unselAll":
app.updateSetting("hub3SelectedDevices", [value: [], type: "enum"]); break
}
app.updateSetting("hub3Action", [value: "none", type: "enum"])
}
input "hub3Enabled", "bool", title:"Enable Hub #3?", defaultValue:false, submitOnChange:true
if (hub3Enabled) {
input "hub3Label", "text", title:"Friendly label for Hub #3 (shown after device name in reports)",
defaultValue:"Hub 3", required:true, submitOnChange:true
input "hub3ShowConn", "bool",
title:"Show / Edit Connection Settings (IP, App ID, Token)",
defaultValue:true, submitOnChange:true
if (settings["hub3ShowConn"]) {
paragraph("Install Maker API on Hub #3, expose all desired battery devices, enter credentials below, then choose Load / Reload Device List from the Action dropdown.")
input "hub3Ip", "text", title:"Hub #3 IP address", required:true, submitOnChange:false
input "hub3AppId", "text", title:"Hub #3 Maker API app ID", required:true, submitOnChange:false
input "hub3Token", "text", title:"Hub #3 Maker API access token", required:true, submitOnChange:false
} else {
def sum = settings["hub3Ip"] ? "Connected to ${settings['hub3Ip']}" : "Not yet configured"
paragraph("Connection: ${sum}. Toggle above to edit.")
}
def hub3Status = state["hub3LoadStatus"]
if (hub3Status) {
def c = hub3Status.startsWith("OK") ? "green" : "red"
paragraph("Last load: ${hub3Status}")
}
input "hub3Action", "enum", title:"Hub #3 Actions", defaultValue:"none",
options:["none":"Choose action…",
"load":"⟳ Load / Reload Device List from Hub #3",
"selAll":"✓ Select All battery devices",
"unselAll":"✗ Clear all selected devices"],
required:false, submitOnChange:true
def hub3Opts = buildRemoteBatteryDeviceOptions(3)
if (hub3Opts == null) {
paragraph("Choose Load / Reload Device List above to fetch battery devices from Hub #3.")
} else if (hub3Opts.size() == 0) {
paragraph("No battery-capable devices returned by Hub #3 Maker API. Check that battery devices are selected in the Maker API app on Hub #3.")
} else {
input "hub3SelectedDevices", "enum",
title:"Battery devices to monitor on Hub #3 (${hub3Opts.size()} available)",
options:hub3Opts, multiple:true, required:false, submitOnChange:true
}
paragraph("Disabled devices: Hubitat's Maker API does not expose disabled state reliably, so disabled remote devices cannot be filtered automatically. If a disabled device appears in reports, deselect it above or enter its ID below to permanently exclude it. The device ID appears in its edit URL: /device/edit/169. Comma-separated.")
input "hub3ExcludeIds", "text",
title:"Hub #3: Manually excluded device IDs (comma-separated)",
required:false, submitOnChange:false
}
}
// ── Notification Settings ─────────────────────────────────────────────
def noticeLabel = noticeSound ? (noticeSound instanceof Collection ? noticeSound*.displayName : [noticeSound.displayName]) : "Select sound 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:false
input "noticeSilent", "capability.notification",
title: "Select Pushover device(s) for silent notifications (remaining reports)",
submitOnChange:false, multiple:true, required:false
}
// ── Report Type, Schedule & 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:false
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 "includeNeverRecent", "bool",
title:"Include devices with a 'Never' battery event in the Last Battery Event report?",
description:"If enabled, devices that have never reported a battery event will be shown in the Last Battery Event report.",
defaultValue:false
input "excludeVirtual", "bool",
title:"Exclude virtual devices from all reports?",
defaultValue:false
input "hideHubColumn", "bool",
title:"Hide the Hub column in all report tables? (Not needed when only one hub is being scanned)",
defaultValue:false
input "enableLogging", "bool", title:"Enable debug logging?", defaultValue:false
paragraph("
App Instance")
input "vAppLabel", "text",
title:"App instance name",
defaultValue:getAppDisplayName(),
submitOnChange:true,
required:false,
width:9
input "btnResetAppLabel", "button",
title:"Reset to App Name",
width:3
}
// ── Sort Options ───────────────────────────────────────────────────────
section(hideable:true, hidden:true, title:"Sort Options") {
paragraph("Note: 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.")
paragraph("
")
// Offline
paragraph("Offline Devices")
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("
")
// Low
paragraph("Low Battery Devices")
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("
")
// Battery
paragraph("Last Battery Event")
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("
")
// Any
paragraph("Last Event (any type)")
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("
")
// Activity
paragraph("Last Activity")
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("
")
}
// ── Notes ─────────────────────────────────────────────────────────────
section(hideable:true, hidden:true, title:"Notes") {
paragraph(
"Report Tables
" +
"Up to five report tables can be enabled independently via Report Type, Schedule & Logging:
" +
"• Offline Devices — devices currently reporting OFFLINE, INACTIVE, or NOT PRESENT.
" +
"• Low Battery Devices — devices at or below the configured low battery warning level; critically low devices shown in red.
" +
"• Last Battery Event — devices that have not reported a battery attribute event within the configured interval. Checks the lastBatteryReport driver attribute first, then lastBattery (e.g. IKEA Parasoll), then falls back to the event log.
" +
"• Last Event (any type) — devices that have not reported any event within the configured interval.
" +
"• Last Activity — devices whose last recorded hub activity exceeds the configured interval.
" +
"
" +
"Multi-Hub Support
" +
"Hubs #2 and #3 are queried via their Maker API on every refresh. Install Maker API on each remote hub, expose the desired battery devices, then enter the IP address, App ID, and Access Token in the hub's section and run ⟳ Load / Reload Device List. " +
"Disabled remote devices cannot be filtered automatically; deselect them in the device picker or enter their device IDs in Manually excluded device IDs to permanently suppress them from all reports.
" +
"
" +
"Scheduling & Notifications
" +
"Reports run automatically once daily at the configured Daily check time and are sent to the configured notification devices. Use Send Report Now for an immediate on-demand send. " +
"The first report goes to the Sound notification device(s); subsequent reports go to the Silent device(s). Reports with no flagged devices produce no notification. Messages are staggered 5 seconds apart to avoid delivery collisions.
" +
"
" +
"Table Features
" +
"• Click any yellow column header to re-sort that table interactively (▲ ascending / ▼ descending). [Never] entries always sort to the end.
" +
"• Interactive sorting is temporary; the saved default sort (also used for notifications) is configured in Sort Options.
" +
"• Device names are clickable links to their hub's device edit page.
" +
"• After each manual refresh, a scan time breakdown appears next to the Last run timestamp (e.g. Scan time: 0:03 [Battery:0.8s, Offline:0.1s…]). Scan time is not shown after scheduled runs.
" +
"
" +
"Display & Behavior Options (in Report Type, Schedule & Logging)
" +
"• App instance name — renames this app instance and updates the page title / Apps list label.
" +
"• Show extra details in section headers — appends current threshold values to collapsed section headers for at-a-glance review without expanding each section.
" +
"• Include devices with \'Never\' battery event — when enabled, devices that have never reported a battery event appear in the Last Battery Event table; when disabled (default) they are suppressed to reduce clutter.
" +
"• Exclude virtual devices — omits virtual devices from all tables and notification output. Detected by driver type name containing \"virtual\" or device name starting with \"VD \". Applies to local and remote hubs.
" +
"• Hide Hub column — removes the Hub column from all tables and notification output. Recommended when only one hub is being monitored.
" +
"• Enable debug logging — writes per-device detail to the Hubitat log during each refresh. Disable when not troubleshooting.
" +
"• App instance name / Reset to App Name — rename this installed app instance, or restore it to the current app code name/version."
)
}
}
}
// Lifecycle
def installed() {
syncAppInstanceLabel()
initialize()
}
def updated() {
unsubscribe()
syncAppInstanceLabel()
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() {
handler(sendNotifications=true)
}
def appButtonHandler(btn) {
switch (btn) {
case "refresh":
case "refresh2":
if (enableLogging) log.debug "Manual refresh requested — page re-render will run the scan"
break
case "sendNow":
if (enableLogging) log.debug "Immediate report send requested"
handler(sendNotifications=true)
break
case "btnResetAppLabel":
resetAppInstanceLabel()
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 scanStart = new Date().time
def htmlOut = ""
def htmlOrder = ["offline","low","battery","any","activity"]
def allReportTypes = ["activity","any","battery","low","offline"]
def selectedReports = (reportTables ?: allReportTypes).findAll { allReportTypes.contains(it) }
if (!selectedReports) selectedReports = 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, recording per-report elapsed time ---
def reportTimings = [:]
def results = selectedReports.collectEntries { type ->
def t0 = new Date().time
def report = (type == "low") ? generateLowBatteryTable(type) :
(type == "activity") ? generateActivityTable(type) :
generateReport(type)
reportTimings[type] = new Date().time - t0
[(type): [label: reportLabels[type], html: report.html, plain: report.plain]]
}
// --- Build HTML for display ---
htmlOrder.each { type ->
def res = results[type]
if (res) htmlOut += "${res.label}
${res.html}
"
}
if (!sendNotifications && state.lastRun) {
def totalElapsed = new Date().time - scanStart
def totalMins = (totalElapsed / 60000).toInteger()
def totalSecs = ((totalElapsed % 60000) / 1000).toInteger()
def totalStr = String.format("%d:%02d", totalMins, totalSecs)
def timingParts = reportTimings.collect { type, ms ->
def lbl = [battery:"Battery", any:"Any", offline:"Offline", low:"Low", activity:"Activity"][type] ?: type
def secs = (ms / 1000).toInteger()
def tenths = ((ms % 1000) / 100).toInteger()
"${lbl}:${secs}.${tenths}s"
}
def timingDetail = timingParts ? " [${timingParts.join(', ')}]" : ""
htmlOut += "
Last run: ${state.lastRun} (Scan time: ${totalStr}${timingDetail})"
}
// --- Add JavaScript for table sorting ---
if (!sendNotifications) {
htmlOut += """
"""
}
// --- 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: pre-filter to reports with content so idx==0 reliably
// identifies the first report actually sent (gets sound); rest get silent.
// Avoids closure mutation of a boolean flag, which is unreliable in Hubitat's sandbox.
def queue = []
def reportsWithContent = allReportTypes.findAll { results[it]?.plain?.trim() }
if (enableLogging) log.debug "Notification routing: ${reportsWithContent.size()} report(s) with content: ${reportsWithContent}"
reportsWithContent.eachWithIndex { type, idx ->
def res = results[type]
def targets = (idx == 0 && soundDevices) ? soundDevices : silentDevices
if (enableLogging) log.debug "Report '${type}' (idx=${idx}) → ${idx == 0 ? 'soundDevices' : 'silentDevices'} (${targets?.size() ?: 0} device(s))"
if (!targets) return
def plainLabel = res.label.replaceAll(/<[^>]+>/, '')
targets.each { dev ->
queue << [deviceId: dev.id, msg: "=== ${plainLabel} ===\n${res.plain.trim()}"]
}
}
// Schedule each message with a 5-second stagger; minimum 1 second (runIn(0,...) is unreliable)
queue.eachWithIndex { item, idx ->
runIn((idx * 5) + 1, "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
// Guard against null settings before wrapping in a list
def soundList = noticeSound ? (noticeSound instanceof Collection ? noticeSound : [noticeSound]) : []
def silentList = noticeSilent ? (noticeSilent instanceof Collection ? noticeSilent : [noticeSilent]) : []
def device = soundList.find { it?.id == deviceId } ?: silentList.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) {
def sortByVal = settings["sortBy_${type}"] ?: ((type=="offline") ? "displayName" : "lastStr")
def sortOrderVal = settings["sortOrder_${type}"] ?: "asc"
def selectedEnabledDevices = (devs ?: []).findAll { !it.isDisabled() }
if (!selectedEnabledDevices && !hasAnyRemoteHubEnabled()) 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 excludeVirt = settings["excludeVirtual"] ?: false
if (excludeVirt) selectedEnabledDevices = selectedEnabledDevices.findAll {
!it.typeName?.toLowerCase()?.contains("virtual") && !it.displayName?.startsWith("VD ")
}
def rightNow = new Date()
def reportList = selectedEnabledDevices.collect { dev ->
def lastEvent = null
def lastEventDate = null
if (type == "battery") {
// 1. Prefer lastBatteryReport attribute (e.g. some Z-Wave / generic drivers)
def lastReportStr = dev.currentValue("lastBatteryReport")
if (lastReportStr) {
try {
lastEventDate = Date.parse("yyyy-MM-dd HH:mm:ss", lastReportStr)
} catch (e) { /* fall through */ }
}
// 2. Try lastBattery attribute (e.g. IKEA Parasoll E2013 and similar Zigbee drivers).
// Format example: "Sun May 24 04:16:37 PDT 2026"
if (!lastEventDate) {
def lastBattStr = dev.currentValue("lastBattery")
if (lastBattStr) {
try {
lastEventDate = new Date(lastBattStr.toString())
} catch (e) { /* fall through to event log */ }
}
}
// 3. Fall back to searching the event log for a battery % change event
if (!lastEventDate) {
lastEvent = dev.events(max:500)?.find { it.name == "battery" }
lastEventDate = lastEvent?.date
}
} else if (type == "any") {
lastEvent = dev.events(max:1)?.getAt(0)
lastEventDate = lastEvent?.date
} else if (type == "offline") {
// Pre-check offline status (cheap) before querying the event log,
// so dev.events() is only called for the small number of actually-offline devices.
def devStatusPre = dev.getStatus()?.toUpperCase()
def isOfflinePre = devStatusPre in ["OFFLINE","INACTIVE","NOT PRESENT"] ||
(dev.currentHealthStatus?.toLowerCase() == "offline")
if (isOfflinePre) {
lastEvent = dev.events(max:1)?.getAt(0)
lastEventDate = lastEvent?.date
}
}
// For "offline" type: lastEventDate only fetched for confirmed-offline devices
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)
: "[Never]"
String lastStrNote = lastEventDate ? lastEventDate.format("yyyy-MM-dd hh:mm a", location.timeZone)
: "0000-00-00 00:00 xx"
def row = [
device : dev,
displayName : dev.displayName,
linkUrl : "/device/edit/${dev.id}",
hubLabel : (settings["hub1Label"] ?: (location.name ?: "Hub 1")),
lastDate : lastEventDate,
lastStrUI : lastStrUI,
lastStrNote : lastStrNote,
lastEventStr: eventDesc,
level : batteryLevel,
offline : isOffline,
needs : needsNotice,
lastActivity: null // resolved lazily below for local devices
]
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") {
return (it.offline == 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) {
if (includeNeverRecentFlag) {
return true // show all [Never] devices (for battery/any reports)
} else {
return false // exclude all [Never] devices
}
}
return false
}
// ── Add remote hub devices ────────────────────────────────────────────────
[2, 3].each { hubNum ->
if (settings["hub${hubNum}Enabled"]) {
reportList.addAll(fetchRemoteReportRows(hubNum, type, rightNow, includeNeverRecentFlag))
}
}
// Sorting for generateReport
def neverList = reportList.findAll { it.lastDate == null }
def normalList = reportList.findAll { it.lastDate != null }
neverList = neverList.sort { (it.device ? it.device.displayName : it.displayName).toLowerCase() }
normalList = normalList.sort { it ->
switch(sortByVal) {
case "displayName": return (it.device ? it.device.displayName : it.displayName).toLowerCase()
case "lastStr": return it.lastDate
case "level": return (it.level.toString().trim().isNumber() ? it.level.toString().trim().toInteger() : -1)
case "lastEventStr":return it.lastEventStr?.toLowerCase() ?: ""
default: return it.lastDate
}
}
if (sortOrderVal == "desc") normalList = normalList.reverse()
// [Never] group always last — matches the JS click-sort behavior
reportList = normalList + neverList
// Compute total checked (local + remote, remote excludes manually excluded IDs)
def totalChecked = selectedEnabledDevices.size() + effectiveRemoteDeviceCount(2) + effectiveRemoteDeviceCount(3)
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","
")
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"
def hideHub = settings["hideHubColumn"] ?: false
tableHtml = ""
def col1Header = (type == "battery") ? "Last Battery Event Time" : "Last Event Time"
tableHtml += "| ${col1Header} | "
tableHtml += "Battery % | "
tableHtml += "Device Name | "
if (type=="any") tableHtml += "Event Description | "
if (!hideHub) tableHtml += "Hub | "
tableHtml += "
"
reportList.each { it ->
def devName = it.device ? it.device.displayName : it.displayName
def devLink = it.device ? "/device/edit/${it.device.id}" : it.linkUrl
tableHtml += ""
tableHtml += "| ${it.lastStrUI} | ${it.level}% | ${devName} | "
if (type=="any") tableHtml += "${it.lastEventStr} | "
if (!hideHub) tableHtml += "${it.hubLabel ?: ''} | "
tableHtml += "
"
}
tableHtml += "
"
}
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"
if (!hideHub) header += " Hub"
rows << header
rows << "-" * header.size()
reportList.each { it ->
def devName = it.device ? it.device.displayName : it.displayName
def row = "${it.lastStrNote} ${it.level.toString()}% ${devName}"
if (type=="any") row += " ${it.lastEventStr}"
if (!hideHub) row += " ${it.hubLabel ?: ''}"
rows << row
}
}
def plainMsg = headerText + (rows ? "\n" + rows.join("\n") : "")
return [label:type, html: headerHtml + tableHtml, plain: plainMsg]
}
// Low Battery Table
private Map generateLowBatteryTable(String type = "low") {
def excludeVirt = settings["excludeVirtual"] ?: false
def selectedEnabledDevices = (devs ?: []).findAll { !it.isDisabled() &&
!(excludeVirt && (it.typeName?.toLowerCase()?.contains("virtual") || it.displayName?.startsWith("VD ")))
}
def lowList = selectedEnabledDevices ? selectedEnabledDevices.collect { dev ->
def level = dev.currentBattery
if (level != null && level <= lowBatteryLevel) {
[device:dev, displayName:dev.displayName, linkUrl:"/device/edit/${dev.id}", hubLabel:(settings["hub1Label"] ?: (location.name ?: "Hub 1")), level:level]
}
}.findAll{ it != null } : []
// ── Add remote hub devices ────────────────────────────────────────────────
[2, 3].each { hubNum ->
if (settings["hub${hubNum}Enabled"]) {
lowList.addAll(fetchRemoteLowBatteryRows(hubNum))
}
}
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 ?: row?.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)
}
}
// Compute total checked (local + remote, remote excludes manually excluded IDs)
def totalChecked = selectedEnabledDevices.size() + effectiveRemoteDeviceCount(2) + effectiveRemoteDeviceCount(3)
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 hideHub = settings["hideHubColumn"] ?: false
def tableHtml = "${summaryText}
"
tableHtml += ""
tableHtml += "| Battery % | "
tableHtml += "Device Name | "
if (!hideHub) tableHtml += "Hub | "
tableHtml += "
"
lowList.each { it ->
def color = it.level <= criticalBatteryLevel ? "red" : "black"
def devName = it.device ? it.device.displayName : it.displayName
def devLink = it.device ? "/device/edit/${it.device.id}" : it.linkUrl
tableHtml += ""
tableHtml += "| ${it.level}% | "
tableHtml += "${devName} | "
if (!hideHub) tableHtml += "${it.hubLabel ?: ''} | "
tableHtml += "
"
}
tableHtml += "
"
def plainRows = lowList.collect { it ->
def devName = it.device ? it.device.displayName : it.displayName
hideHub ? "${it.level}% ${devName}" : "${it.level}% ${devName} ${it.hubLabel ?: ''}"
}
def plainMsg = "${summaryText}\n\n" + plainRows.join("\n")
return [label:"Low Battery", html: tableHtml, plain: plainMsg]
}
// Last Activity Table
private Map generateActivityTable(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 excludeVirt = settings["excludeVirtual"] ?: false
def selectedEnabledDevices = (devs ?: []).findAll { !it.isDisabled() &&
!(excludeVirt && (it.typeName?.toLowerCase()?.contains("virtual") || it.displayName?.startsWith("VD ")))
}
if (!selectedEnabledDevices && !hasAnyRemoteHubEnabled()) return [label:"Last Activity", html:"No battery devices found.", plain:"No battery devices found."]
def rightNow = new Date()
def reportList = selectedEnabledDevices ? 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 = "${formatted}"
lastStrNote = formatted
} else {
lastStrUI = "[Never]"
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, displayName:dev.displayName, linkUrl:"/device/edit/${dev.id}",
hubLabel:(settings["hub1Label"] ?: (location.name ?: "Hub 1")),
lastActivity:lastActivity, lastStrUI:lastStrUI, lastStrNote:lastStrNote, level:batteryLevel, overdue:overdue]
}.findAll { it.overdue } : []
// ── Add remote hub devices ────────────────────────────────────────────────
[2, 3].each { hubNum ->
if (settings["hub${hubNum}Enabled"]) {
reportList.addAll(fetchRemoteActivityRows(hubNum, rightNow, thresholdMillis))
}
}
// 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 ? it.device.displayName : it.displayName).toLowerCase() }
// Sort normal group by selected sort key
normalList = normalList.sort { it ->
switch(sortByVal) {
case "displayName": return (it.device ? it.device.displayName : it.displayName).toLowerCase()
case "lastActivity":return it.lastActivity
case "level": return (it.level.toString().trim().isNumber() ? it.level.toString().trim().toInteger() : -1)
default: return it.lastActivity
}
}
if (sortOrderVal == "desc") normalList = normalList.reverse()
// [Never] group always last — matches the JS click-sort behavior
reportList = normalList + neverList
// Compute total checked (local + remote, remote excludes manually excluded IDs)
def totalChecked = selectedEnabledDevices.size() + effectiveRemoteDeviceCount(2) + effectiveRemoteDeviceCount(3)
def overdueCount = reportList.size()
def summaryText = (overdueCount > 0) ?
"${overdueCount} of ${totalChecked} selected devices did not report any activity within ${activityThresholdHours}h." :
"${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 hideHub = settings["hideHubColumn"] ?: false
def tableHtml = "${summaryText}
"
if (overdueCount > 0) {
tableHtml += ""
tableHtml += "| Last Activity | "
tableHtml += "Battery % | "
tableHtml += "Device Name | "
if (!hideHub) tableHtml += "Hub | "
tableHtml += "
"
reportList.each { it ->
def devName = it.device ? it.device.displayName : it.displayName
def devLink = it.device ? "/device/edit/${it.device.id}" : it.linkUrl
tableHtml += ""
tableHtml += "| ${it.lastStrUI} | "
tableHtml += "${it.level}% | "
tableHtml += "${devName} | "
if (!hideHub) tableHtml += "${it.hubLabel ?: ''} | "
tableHtml += "
"
}
tableHtml += "
"
}
// Plain text version
def plainRows = []
if (overdueCount > 0) {
plainRows = reportList.collect { row ->
def devName = row.device ? row.device.displayName : row.displayName
hideHub ? "${row.lastStrNote} ${row.level}% ${devName}" : "${row.lastStrNote} ${row.level}% ${devName} ${row.hubLabel ?: ''}"
}
}
def plainMsg = "${summaryText}\n\n" + (plainRows ? plainRows.join("\n") : "")
return [label:"Last Activity", html: tableHtml, plain: plainMsg]
}
// ─────────────────────────────────────────────────────────────────────────────
// REMOTE HUB HELPERS
// ─────────────────────────────────────────────────────────────────────────────
// Returns true if at least one remote hub is enabled (used to avoid early-return on empty local devs)
private boolean hasAnyRemoteHubEnabled() {
return (settings["hub2Enabled"] || settings["hub3Enabled"])
}
// Count of selected remote devices on a hub AFTER applying manual exclusions,
// so totalChecked denominators match what the report actually evaluates.
private int effectiveRemoteDeviceCount(int hubNum) {
if (!settings["hub${hubNum}Enabled"]) return 0
def selIds = normalizeRemoteSelectionList(settings["hub${hubNum}SelectedDevices"])
def exclIds = (settings["hub${hubNum}ExcludeIds"] ?: "").split(",").collect { it.trim() }.findAll { it } as Set
return selIds.count { !exclIds.contains(it) } as int
}
// Normalise a multi-select setting into a List
private List normalizeRemoteSelectionList(def raw) {
if (raw instanceof List) return raw*.toString()
if (raw instanceof Collection) return raw.collect { it.toString() }
return raw ? [raw.toString()] : []
}
// Load battery-capable devices from a remote hub via Maker API and cache in state
private void loadRemoteBatteryDeviceList(int hubNum, String ip, String appId, String token) {
def hubLabel = settings["hub${hubNum}Label"] ?: "Hub ${hubNum}"
if (!ip || !appId || !token) {
state["hub${hubNum}BatteryDevices"] = []
state["hub${hubNum}LoadStatus"] = "Error: missing IP, app ID, or token"
return
}
def uri = "http://${ip}/apps/api/${appId}/devices?access_token=${token}"
def batteryList = []
try {
httpGet([uri:uri, contentType:"application/json", timeout:15]) { resp ->
if (resp.status != 200) {
state["hub${hubNum}BatteryDevices"] = []
state["hub${hubNum}LoadStatus"] = "Error: HTTP ${resp.status}"
return
}
resp.data?.each { dev ->
def isDisabled = dev.disabled == true || dev.disabled?.toString() == "true" ||
(dev.status ?: "").toString().toUpperCase() == "DISABLED"
if (!isDisabled) {
batteryList << [
id : dev.id?.toString(),
name: (dev.label ?: dev.name ?: "Unknown").toString(),
room: (dev.room ?: "").toString()
]
}
}
}
state["hub${hubNum}BatteryDevices"] = batteryList
state["hub${hubNum}LoadStatus"] = "OK: ${batteryList.size()} device${batteryList.size() == 1 ? '' : 's'} loaded (non-battery devices filtered during report generation)"
log.info "${hubLabel}: Loaded ${batteryList.size()} device(s) for monitoring."
} catch (Exception e) {
log.error "${hubLabel}: Error loading device list — ${e.message}"
state["hub${hubNum}BatteryDevices"] = []
state["hub${hubNum}LoadStatus"] = "Error: ${e.message}"
}
}
// Build the options map for the device-selector dropdown in mainPage
private Map buildRemoteBatteryDeviceOptions(int hubNum) {
def stored = state["hub${hubNum}BatteryDevices"]
if (stored == null) return null // not yet loaded
if (!stored) return [:] // loaded but empty
return stored.sort { it.name }.collectEntries { dev ->
def label = dev.name + (dev.room ? " (${dev.room})" : "")
["${dev.id}": label]
}
}
// ── Fetch rows for generateReport (offline / battery / any) ──────────────────
// Makes one /devices/{id} call per device for attributes+status, then one
// /devices/{id}/events call for event timestamps when the report type needs them.
// Returns pre-filtered rows in the same map format used by generateReport.
private List fetchRemoteReportRows(int hubNum, String type, Date rightNow, boolean includeNeverRecentFlag) {
def results = []
def hubLabel = settings["hub${hubNum}Label"] ?: "Hub ${hubNum}"
def ip = settings["hub${hubNum}Ip"]
def appId = settings["hub${hubNum}AppId"]
def token = settings["hub${hubNum}Token"]
def selectedIds = normalizeRemoteSelectionList(settings["hub${hubNum}SelectedDevices"])
def excludeIds = (settings["hub${hubNum}ExcludeIds"] ?: "").split(",").collect { it.trim() }.findAll { it } as Set
if (!selectedIds || !ip || !appId || !token) return results
def batteryThresholdHours = (settings["batteryIntervalHours"] ?: 24) as int
def activityThresholdHours = (settings["activityIntervalHours"] ?: 24) as int
def fs = '\u2007'
selectedIds.each { devId ->
if (excludeIds.contains(devId)) return
try {
def displayName = null
def batteryLevel = null
def isOffline = false
def devType = ""
def hasBattery = false
Date lastBatteryReportDate = null
Date lastActivity = null
// ── Step 1: fetch device details ──────────────────────────────────
httpGet([uri:"http://${ip}/apps/api/${appId}/devices/${devId}?access_token=${token}",
contentType:"application/json", timeout:10]) { resp ->
if (resp.status != 200 || !resp.data) return
def dev = resp.data
displayName = (dev.label ?: dev.name ?: "Unknown").toString()
devType = (dev.type ?: "").toString().toLowerCase()
def rawStatus = (dev.status ?: "").toString().toUpperCase()
isOffline = rawStatus in ["OFFLINE","INACTIVE","NOT PRESENT"]
def attrs = dev.attributes
def battAttr = null
def lbrAttr = null
def lbAttr = null
if (attrs instanceof List) {
battAttr = attrs.find { a -> a?.name?.toString() == "battery" }
lbrAttr = attrs.find { a -> a?.name?.toString() == "lastBatteryReport" }
lbAttr = attrs.find { a -> a?.name?.toString() == "lastBattery" }
} else if (attrs instanceof Map) {
battAttr = [currentValue: attrs["battery"]]
lbrAttr = [currentValue: attrs["lastBatteryReport"]]
lbAttr = [currentValue: attrs["lastBattery"]]
}
def rawBatt = battAttr?.currentValue
batteryLevel = (rawBatt != null && rawBatt.toString().isNumber()) ? rawBatt.toDouble() : null
hasBattery = (battAttr != null)
// 1. lastBatteryReport attribute
def lbrVal = lbrAttr?.currentValue
if (lbrVal) {
try { lastBatteryReportDate = Date.parse("yyyy-MM-dd HH:mm:ss", lbrVal.toString()) } catch (e) {}
}
// 2. lastBattery attribute (e.g. IKEA Parasoll E2013 and similar Zigbee drivers)
// Format example: "Sun May 24 04:16:37 PDT 2026"
if (!lastBatteryReportDate) {
def lbVal = lbAttr?.currentValue
if (lbVal) {
try { lastBatteryReportDate = new Date(lbVal.toString()) } catch (e) {}
}
}
lastActivity = parseRemoteLastActivity(dev.lastActivity, hubLabel, devId)
}
if (!displayName) return // device fetch failed
if (!hasBattery) return // no battery attribute — not a battery device
def excludeVirt = settings["excludeVirtual"] ?: false
if (excludeVirt && (devType.contains("virtual") || displayName.startsWith("VD "))) return
// ── Step 2: fetch events when needed (battery / any types) ────────
Date lastBatteryEventDate = lastBatteryReportDate
Date lastAnyEventDate = null
def lastAnyEventName = ""
def lastAnyEventValue = ""
if (type in ["battery", "any"]) {
try {
httpGet([uri:"http://${ip}/apps/api/${appId}/devices/${devId}/events?access_token=${token}",
contentType:"application/json", timeout:10]) { evResp ->
if (evResp.status == 200 && evResp.data instanceof List && evResp.data.size() > 0) {
def events = evResp.data
// Most-recent event (first in list)
lastAnyEventDate = parseRemoteLastActivity(events[0].date ?: events[0].time, hubLabel, "${devId}-any")
lastAnyEventName = events[0].name?.toString() ?: ""
lastAnyEventValue = events[0].value?.toString() ?: ""
// Last battery event (if not already from lastBatteryReport attribute)
if (lastBatteryEventDate == null) {
def battEv = events.find { e -> e.name?.toString() == "battery" }
if (battEv) lastBatteryEventDate = parseRemoteLastActivity(battEv.date ?: battEv.time, hubLabel, "${devId}-batt")
}
// Last activity fallback
if (lastActivity == null) lastActivity = lastAnyEventDate
}
}
} catch (evEx) {
if (enableLogging) log.debug "${hubLabel} device ${devId}: events fetch failed — ${evEx.message}"
}
}
// ── Step 3: determine lastEventDate, eventDesc, needsNotice ───────
Date lastEventDate = null
def eventDesc = ""
def needsNotice = false
if (type == "offline") {
needsNotice = isOffline
} else if (type == "battery") {
lastEventDate = lastBatteryEventDate
if (lastEventDate != null) {
needsNotice = ((rightNow.time - lastEventDate.time) / 60000) > (batteryThresholdHours * 60)
}
} else if (type == "any") {
lastEventDate = lastAnyEventDate
eventDesc = lastAnyEventDate ? "(Event: ${lastAnyEventName} ${lastAnyEventValue})" : ""
needsNotice = !lastEventDate || ((rightNow.time - lastEventDate.time) / 60000) > ((eventIntervalHours ?: 24) * 60)
}
// ── Step 4: apply same include/exclude filter as local devices ────
def includeRow = false
if (type == "offline") {
includeRow = needsNotice
} else if (lastEventDate != null && needsNotice) {
includeRow = true
} else if (lastEventDate == null) {
includeRow = includeNeverRecentFlag
}
if (!includeRow) return
def levelStr = batteryLevel != null ? Math.round(batteryLevel).toString().padLeft(3, fs) : "N/A".padLeft(3, fs)
String lastStrUI = lastEventDate ? lastEventDate.format("yyyy-MM-dd hh:mm a", location.timeZone) : "[Never]"
String lastStrNote = lastEventDate ? lastEventDate.format("yyyy-MM-dd hh:mm a", location.timeZone) : "0000-00-00 00:00 xx"
results << [
device : null,
displayName : displayName,
hubLabel : hubLabel,
linkUrl : "http://${ip}/device/edit/${devId}",
lastDate : lastEventDate,
lastStrUI : lastStrUI,
lastStrNote : lastStrNote,
lastEventStr: eventDesc,
level : levelStr,
offline : isOffline,
needs : needsNotice,
lastActivity: lastActivity
]
if (enableLogging) log.debug "DBG-REMOTE-${type?.toUpperCase()}: hub=${hubLabel} device='${displayName}' | lastDate=${lastEventDate} | offline=${isOffline} | needs=${needsNotice}"
} catch (Exception e) {
log.warn "${hubLabel} device ${devId}: error in fetchRemoteReportRows — ${e.message}"
}
}
return results
}
// ── Fetch rows for generateLowBatteryTable ────────────────────────────────────
private List fetchRemoteLowBatteryRows(int hubNum) {
def results = []
def hubLabel = settings["hub${hubNum}Label"] ?: "Hub ${hubNum}"
def ip = settings["hub${hubNum}Ip"]
def appId = settings["hub${hubNum}AppId"]
def token = settings["hub${hubNum}Token"]
def selectedIds = normalizeRemoteSelectionList(settings["hub${hubNum}SelectedDevices"])
def excludeIds = (settings["hub${hubNum}ExcludeIds"] ?: "").split(",").collect { it.trim() }.findAll { it } as Set
if (!selectedIds || !ip || !appId || !token) return results
selectedIds.each { devId ->
if (excludeIds.contains(devId)) return
try {
httpGet([uri:"http://${ip}/apps/api/${appId}/devices/${devId}?access_token=${token}",
contentType:"application/json", timeout:10]) { resp ->
if (resp.status != 200 || !resp.data) return
def dev = resp.data
def displayName = (dev.label ?: dev.name ?: "Unknown").toString()
def devType = (dev.type ?: "").toString().toLowerCase()
def excludeVirt = settings["excludeVirtual"] ?: false
if (excludeVirt && (devType.contains("virtual") || displayName.startsWith("VD "))) return
def attrs = dev.attributes
def battAttr = null
if (attrs instanceof List) {
battAttr = attrs.find { a -> a?.name?.toString() == "battery" }
} else if (attrs instanceof Map) {
battAttr = [currentValue: attrs["battery"]]
}
def rawBatt = battAttr?.currentValue
if (battAttr == null) return // no battery attribute — not a battery device
def level = (rawBatt != null && rawBatt.toString().isNumber()) ? rawBatt.toDouble() : null
if (level != null && level <= lowBatteryLevel) {
results << [
device : null,
displayName: displayName,
hubLabel : hubLabel,
linkUrl : "http://${ip}/device/edit/${devId}",
level : level
]
}
}
} catch (Exception e) {
log.warn "${hubLabel} device ${devId}: error in fetchRemoteLowBatteryRows — ${e.message}"
}
}
return results
}
// ── Fetch rows for generateActivityTable ─────────────────────────────────────
private List fetchRemoteActivityRows(int hubNum, Date rightNow, long thresholdMillis) {
def results = []
def hubLabel = settings["hub${hubNum}Label"] ?: "Hub ${hubNum}"
def ip = settings["hub${hubNum}Ip"]
def appId = settings["hub${hubNum}AppId"]
def token = settings["hub${hubNum}Token"]
def selectedIds = normalizeRemoteSelectionList(settings["hub${hubNum}SelectedDevices"])
def excludeIds = (settings["hub${hubNum}ExcludeIds"] ?: "").split(",").collect { it.trim() }.findAll { it } as Set
if (!selectedIds || !ip || !appId || !token) return results
def fs = '\u2007'
selectedIds.each { devId ->
if (excludeIds.contains(devId)) return
try {
def displayName = null
def batteryLevel = null
def devType = ""
def hasBattery = false
Date lastActivity = null
// Fetch device details
httpGet([uri:"http://${ip}/apps/api/${appId}/devices/${devId}?access_token=${token}",
contentType:"application/json", timeout:10]) { resp ->
if (resp.status != 200 || !resp.data) return
def dev = resp.data
displayName = (dev.label ?: dev.name ?: "Unknown").toString()
devType = (dev.type ?: "").toString().toLowerCase()
lastActivity = parseRemoteLastActivity(dev.lastActivity, hubLabel, devId)
def attrs = dev.attributes
def battAttr = null
if (attrs instanceof List) {
battAttr = attrs.find { a -> a?.name?.toString() == "battery" }
} else if (attrs instanceof Map) {
battAttr = [currentValue: attrs["battery"]]
}
hasBattery = (battAttr != null)
def rawBatt = battAttr?.currentValue
batteryLevel = (rawBatt != null && rawBatt.toString().isNumber()) ? rawBatt.toInteger() : null
}
if (!displayName) return // device fetch failed
if (!hasBattery) return // no battery attribute — not a battery device
def excludeVirt = settings["excludeVirtual"] ?: false
if (excludeVirt && (devType.contains("virtual") || displayName.startsWith("VD "))) return
// Fallback: derive lastActivity from most-recent event
if (lastActivity == null) {
try {
httpGet([uri:"http://${ip}/apps/api/${appId}/devices/${devId}/events?access_token=${token}",
contentType:"application/json", timeout:10]) { evResp ->
if (evResp.status == 200 && evResp.data instanceof List && evResp.data.size() > 0) {
lastActivity = parseRemoteLastActivity(evResp.data[0].date ?: evResp.data[0].time, hubLabel, "${devId}-evt")
}
}
} catch (evEx) {
if (enableLogging) log.debug "${hubLabel} device ${devId}: events fetch failed — ${evEx.message}"
}
}
def overdue = lastActivity ? (rightNow.time - lastActivity.time > thresholdMillis) : true
if (!overdue) return
def levelStr = batteryLevel != null ? batteryLevel.toString().padLeft(3, fs) : "N/A".padLeft(3, fs)
String lastStrUI, lastStrNote
if (lastActivity) {
def formatted = lastActivity.format("yyyy-MM-dd hh:mm a", location.timeZone)
lastStrUI = "${formatted}"
lastStrNote = formatted
} else {
lastStrUI = "[Never]"
lastStrNote = "0000-00-00 00:00 xx"
}
results << [
device : null,
displayName : displayName,
hubLabel : hubLabel,
linkUrl : "http://${ip}/device/edit/${devId}",
lastActivity: lastActivity,
lastStrUI : lastStrUI,
lastStrNote : lastStrNote,
level : levelStr,
overdue : true
]
} catch (Exception e) {
log.warn "${hubLabel} device ${devId}: error in fetchRemoteActivityRows — ${e.message}"
}
}
return results
}
// Parse a lastActivity value returned by the Maker API. Handles:
// • Long / Number — epoch milliseconds
// • Numeric string — epoch milliseconds as string
// • Date string — "yyyy-MM-dd HH:mm:ss±HHmm", ISO-8601 with T, positive or NEGATIVE offset
// Both + and – timezone offsets are stripped before parsing so US hubs (-05:00 etc.) work correctly.
private Date parseRemoteLastActivity(def laVal, String hubLabel, def devId) {
if (laVal == null) return null
try {
if (laVal instanceof Number) return new Date(laVal.toLong())
def laStr = laVal.toString().trim()
if (!laStr || laStr == "null") return null
if (laStr.isLong()) return new Date(laStr.toLong())
// Replace T separator, then strip trailing ±HH:mm or ±HHmm (handles both signs)
def raw = laStr.replace('T', ' ').replaceAll(/[+\-]\d{2}:?\d{2}$/, '').trim()
if (raw) return Date.parse("yyyy-MM-dd HH:mm:ss", raw)
} catch (pe) {
if (enableLogging) log.warn "${hubLabel} device ${devId}: could not parse lastActivity '${laVal}' — ${pe.message}"
}
return null
}