" +
"OAuth is not yet enabled. To activate the web portal:
" +
"1. Go to Apps Code in the Hubitat menu" + (hubIp ? " — tap here to open Apps Code" : "") + " " +
"2. Find Device Health Monitor in the list and open it " +
"3. Click OAuth in the top-right of the code editor " +
"4. Click Enable OAuth in App → Update " +
"5. Return here and tap Done to save — the portal URLs will appear above." +
"
"
}
}
def devicesSelected = (monitoredDevices?.size() ?: 0) > 0
def devSectionTitle = devicesSelected
? "Monitored Devices — ${monitoredDevices.size()} selected"
: "Monitored Devices"
section(devSectionTitle, hideable: true, hidden: devicesSelected) {
paragraph "Select the devices you want to monitor. Protocol is detected automatically."
paragraph "IMPORTANT: After selecting devices, you MUST click 'Done' before viewing reports."
input "monitoredDevices", "capability.*",
title: "Select devices to monitor",
multiple: true, required: false, submitOnChange: true
}
if (devicesSelected) {
def allSelected = monitoredDevices
def zigbeeCount = allSelected.count { getProtocol(it) in ["Zigbee", "Hub Mesh (Zigbee)"] }
def zwaveCount = allSelected.count { getProtocol(it) in ["Z-Wave", "Hub Mesh (Z-Wave)"] }
def matterCount = allSelected.count { getProtocol(it) in ["Matter", "Hub Mesh (Matter)"] }
def hubMeshCount = allSelected.count { getProtocol(it) == "Hub Mesh" }
def lanCount = allSelected.count { getProtocol(it) == "LAN" }
def virtualCount = allSelected.count { getProtocol(it) == "Virtual" }
def hubVarCount = allSelected.count { getProtocol(it) == "Hub Variable" }
def unknownCount = allSelected.count { getProtocol(it) == "Unknown" }
def unresolvableCount = allSelected.count { isUnresolvableProtocol(getRawProtocol(it)) }
section("") {
paragraph "Zigbee: ${zigbeeCount} | " +
"Z-Wave: ${zwaveCount} | " +
"Matter: ${matterCount} | " +
"Hub Mesh: ${hubMeshCount} | " +
"LAN: ${lanCount} | " +
"Virtual: ${virtualCount} | " +
"Hub Variable: ${hubVarCount}" +
(unknownCount > 0 ? " | Unknown: ${unknownCount} (skipped)" : "") +
(unresolvableCount > 0 ? " ⚠ ${unresolvableCount} device(s) showing as Hub Mesh, LAN, Virtual, or Hub Variable — tap Protocol Overrides to review or correct." : "") +
(allSelected.any { isHueDevice(it) } && !findHueBridge() ? " ℹ️ Hue devices detected — add your Hue Bridge to monitored devices to enable Poor/Offline verification." : "")
}
}
if (!devicesSelected) {
section("") {
paragraph "⚠ No devices selected. Select devices above to begin monitoring."
}
}
def scanIntervalLabel = ["0.5": "Every 30 min", "1": "Hourly", "3": "Every 3 h", "6": "Every 6 h"]
def currentScan = scanIntervalLabel[settings?.scanInterval ?: "3"] ?: "Every 3 h"
def currentThreshold = settings?.offlineThresholdHours ?: 168
def snoozeOn = snoozeEnabled()
def currentSnooze = settings?.snoozeDurationHours ?: 24
def modeOn = settings?.enableModeRestriction == true
def modeLabel = modeOn ? (settings?.restrictedModes ? settings.restrictedModes.join(", ") : "none set") : "off"
def snoozedDeviceCount = state.snoozed?.count { id, until -> until >= now() } ?: 0
def scanningLabel = state.isScanning ? " | 🔄 Scanning..." : ""
def snoozeLabel = !snoozeOn ? "off" :
snoozedDeviceCount > 0 ? "${snoozedDeviceCount} snoozed" :
"${currentSnooze}h"
def monitoringTitle = "Monitoring Settings — " +
"Scan: ${currentScan} | " +
"Offline after: ${currentThreshold}h | " +
"Snooze: ${snoozeLabel} | " +
"Mode: ${modeOn ? modeLabel : "off"}${scanningLabel}"
section(monitoringTitle, hideable: true, hidden: true) {
paragraph "Scan Interval — how often device activity is checked and health ratings are updated."
input "scanInterval", "enum",
title: "Scan Frequency:",
options: ["0.5": "Every 30 Minutes", "1": "Hourly", "3": "Every 3 Hours", "6": "Every 6 Hours"],
defaultValue: "3", submitOnChange: true
paragraph "Offline after inactivity (hours) — devices with no activity beyond this threshold are marked Offline."
input "offlineThresholdHours", "number", title: "Offline after inactivity (hours):",
defaultValue: 168, required: true, submitOnChange: true
paragraph "Snooze — enable or disable snooze globally."
input "enableSnooze", "bool", title: "Enable snooze", defaultValue: false, submitOnChange: true
if (snoozeEnabled()) {
input "snoozeDurationHours", "number", title: "Snooze duration (hours):",
defaultValue: 24, required: true, submitOnChange: true
}
def deepResult = state.deepScanResult
def deepResultStr = deepResult ? new Date(deepResult.ranAt).format("MM/dd/yy h:mm a", location.timeZone) +
" — ${deepResult.verified} verified, ${deepResult.unverifiable} unverifiable, ${deepResult.declared} still declared" : "Never run"
def deepEnabled = settings?.enableDeepScan == true
def deepTitle = "Deep Verification Scan — ${deepEnabled ? "Scheduled" : "Off"}"
paragraph ""
paragraph deepTitle
paragraph "Last run: ${deepResultStr}"
input "enableDeepScan", "bool", title: "Schedule — runs once then auto-disables:", defaultValue: false, submitOnChange: true
if (deepEnabled) {
input "deepScanTime", "time", title: "Run at:", required: true
}
input "btnRunDeepScan", "button", title: "▶ Run Now"
paragraph ""
paragraph "Mode Restriction — optionally restrict notifications to specific hub modes."
input "enableModeRestriction", "bool", title: "Enable mode restriction for notifications",
defaultValue: false, submitOnChange: true
if (settings?.enableModeRestriction) {
input "restrictedModes", "mode",
title: "Only send notifications when hub is in one of these modes:",
multiple: true, required: false
}
}
def notifOn = settings?.enablePush != false
def notifSectionTitle = "Notifications — ${notifOn ? "ON" : "OFF"}"
section(notifSectionTitle, hideable: true, hidden: true) {
input "enablePush", "bool", title: "Enable notifications", defaultValue: false
input "reportFrequency", "enum",
title: "Notification Frequency:",
options: ["daily": "Daily", "every2": "Every 2 Days", "every3": "Every 3 Days", "weekly": "Weekly"],
defaultValue: "daily"
input "summaryTime", "time", title: "Notification Time:", required: false
input "notifyDevices", "capability.notification",
title: "Notification devices", multiple: true, required: false
input "enablePushover", "bool", title: "⚙️ Enable Pushover Markup", defaultValue: false
input "pushoverDevices", "capability.notification",
title: "Pushover notification devices", multiple: true, required: false
input "pushoverPrefix", "text",
title: "Pushover tags",
description: "e.g. [H][TITLE=Device Health Report][HTML][SELFDESTRUCT=43200]",
required: false
paragraph "Report Sections:"
input "notifyOffline", "bool", title: "💀 Include Offline devices", defaultValue: true
input "notifyPoor", "bool", title: "🔴 Include Poor health devices", defaultValue: true
input "notifyFair", "bool", title: "🟠 Include Fair health devices", defaultValue: true
input "notifyGood", "bool", title: "🟢 Include Good health devices", defaultValue: false
input "notifyExcellent", "bool", title: "🟢 Include Excellent health devices", defaultValue: false
input "suppressEmptyReport", "bool", title: "🔕 Don't send notification if nothing to report", defaultValue: false
paragraph "Send notification now:"
href(name: "toSendNotification", page: "sendNotificationPage", title: "📤 Send Notification Now")
}
section("Reports:") {
href(name: "toActivitySummary", page: "activitySummaryPage",
title: "Device Activity Summary",
description: "All devices, health status, current state")
href(name: "toProblemDevices", page: "problemDevicesPage",
title: "⚠️ Problem Devices & Verification",
description: "Active issues, unverifiable devices, and verification status")
if (getAllMonitoredDevices().any { getProtocol(it).startsWith("Hub Mesh") }) {
href(name: "toHubMeshSummary", page: "hubMeshSummaryPage",
title: "🔗 Hub Mesh Overview",
description: "Health summary grouped by source hub")
}
href(name: "toLocationAssign", page: "locationAssignPage",
title: "🏷️ Location Assignment",
description: "Assign rooms and descriptions to devices — used in portal")
if (snoozeEnabled()) {
href(name: "toSnoozeManage", page: "snoozeManagePage",
title: "😴 Manage Snoozed Devices",
description: "Snooze or clear active snoozes")
}
href(name: "toProtocolOverride", page: "protocolOverridePage",
title: "🔧 Protocol & State Overrides",
description: "Fix misdetected protocols or pin a specific state attribute per device")
}
section("Help & Support") {
href(name: "toInfoPage", page: "infoPage",
title: "📖 App Guide & Reference",
description: "Health scoring, state tracking, portal setup, and troubleshooting explained")
href url: "https://community.hubitat.com/t/release-device-health-monitor/163229",
style: "external",
title: "💬 Hubitat Community Thread",
description: "Questions, feedback, and release notes"
}
section("Diagnostics") {
input "debugMode", "bool",
title: "Debug Logging (auto-disables after 30 min)",
defaultValue: false, submitOnChange: true
paragraph "Device Health Monitor v1.5.3"
}
}
}
// ============================================================
// ===================== LOCATION ASSIGNMENT PAGE ============
// ============================================================
def getRoomOptions() {
def locs = []
(1..30).each { i ->
def v = settings["loc${i}"] ?: ""
def t = v.trim()
if (t != "") locs << t
}
return locs.sort()
}
def locationAssignPage() {
def roomOptions = getRoomOptions()
def devList = getAllMonitoredDevices()
.findAll { getProtocol(it) != "Unknown" }
.sort { a, b -> a.displayName.trim() <=> b.displayName.trim() }
dynamicPage(name: "locationAssignPage", title: "Location Assignment", install: false) {
def hasLocs = getRoomOptions().size() > 0
def locCount = getRoomOptions().size()
def locTitle = hasLocs
? "Locations — ${locCount} defined"
: "Locations"
section(locTitle, hideable: true, hidden: hasLocs) {
paragraph "Enter your room and area names below. Leave unused boxes blank."
input "btnSaveLocations", "button", title: "💾 Save Locations"
(1..10).each { i ->
def col1 = i
def col2 = i + 10
def col3 = i + 20
input "loc${col1}", "text",
title: (settings["loc${col1}"] ?: "") != "" ? "✅ Location ${col1}" : "Location ${col1}",
defaultValue: settings["loc${col1}"] ?: "",
required: false, width: 4
input "loc${col2}", "text",
title: (settings["loc${col2}"] ?: "") != "" ? "✅ Location ${col2}" : "Location ${col2}",
defaultValue: settings["loc${col2}"] ?: "",
required: false, width: 4
input "loc${col3}", "text",
title: (settings["loc${col3}"] ?: "") != "" ? "✅ Location ${col3}" : "Location ${col3}",
defaultValue: settings["loc${col3}"] ?: "",
required: false, width: 4
paragraph ""
}
}
if (!devList || devList.size() == 0) {
section("") { paragraph "No monitored devices found. Select devices on the main page first." }
return
}
if (roomOptions.size() > 0) {
section("Assign Devices to a Room") {
paragraph "Select a room — devices already assigned to it will be pre-checked. Check or uncheck devices, then confirm to save."
def devOptions = devList.collectEntries { [(it.id): it.displayName] }.sort { a, b -> a.value <=> b.value }
input "bulkLoc", "enum",
title: "Room:",
options: roomOptions,
required: false,
submitOnChange: true
if (settings?.bulkLoc) {
def selectedRoom = settings.bulkLoc
def currentlyInRoom = devList
.findAll { getDeviceLocation(it.id) == selectedRoom }
.collect { it.id as String }
if (state.lastBulkLoc != selectedRoom) {
state.lastBulkLoc = selectedRoom
if (currentlyInRoom) {
app.updateSetting("bulkDevs", [type: "enum", value: currentlyInRoom])
} else {
app.removeSetting("bulkDevs")
}
}
def count = currentlyInRoom.size()
paragraph "${count} device(s) currently assigned to ${selectedRoom}"
input "bulkDevs", "enum",
title: "Devices in ${selectedRoom}:",
options: devOptions,
multiple: true,
required: false
input "bulkApplyConfirm", "bool",
title: "Confirm — save device assignments for ${selectedRoom}",
defaultValue: false,
submitOnChange: true
if (settings?.bulkApplyConfirm == true) {
def newDevIds = settings.bulkDevs instanceof List ? settings.bulkDevs :
settings.bulkDevs ? [settings.bulkDevs] : []
def addedCount = 0
def removedCount = 0
newDevIds.each { dId ->
if (getDeviceLocation(dId) != selectedRoom) {
setDeviceLocation(dId, selectedRoom)
addedCount++
}
}
currentlyInRoom.each { dId ->
if (!newDevIds.contains(dId)) {
setDeviceLocation(dId, "")
removedCount++
}
}
state.lastBulkLoc = null
app.updateSetting("bulkApplyConfirm", [value: false, type: "bool"])
def msg = "✅ ${selectedRoom} updated"
if (addedCount > 0) msg += " — ${addedCount} added"
if (removedCount > 0) msg += " — ${removedCount} removed"
paragraph msg
}
}
}
def unassigned = devList.findAll { !getDeviceLocation(it.id) }
def assigned = devList.findAll { getDeviceLocation(it.id) }
section("Device Summary") {
paragraph "Assigned: ${assigned.size()} | Unassigned: ${unassigned.size()} | Total: ${devList.size()}"
}
section("Individual Devices", hideable: true, hidden: true) {
paragraph "For faster assignment use the web portal — tap any device card to set its location."
devList.each { device ->
def currentLoc = getDeviceLocation(device.id)
def currentDesc = settings["desc_${device.id}"] ?: ""
def h = state.health?.get(device.id) ?: "Pending"
def protocol = getProtocol(device)
def tag = currentLoc ? "🏷️ ${currentLoc}" : "unassigned"
paragraph "${device.displayName} ${tag} ${h} · ${protocol}"
input "loc_${device.id}", "enum",
title: "Location:",
options: roomOptions,
defaultValue: currentLoc,
required: false,
width: 6
input "desc_${device.id}", "text",
title: "Description:",
defaultValue: currentDesc,
required: false,
width: 6
paragraph ""
}
}
} else {
section("") {
paragraph "Enter your locations above and tap Done — dropdowns and the portal will populate automatically."
}
}
}
}
// ============================================================
// ===================== REPORT SCHEDULING ==================
// ============================================================
def scheduleReportFrequency() {
unschedule("reportScheduler")
if (!summaryTime) return
schedule(summaryTime, reportScheduler)
}
def scheduleDeepVerificationScan() {
unschedule("runDeepVerificationScan")
if (settings?.enableDeepScan && settings?.deepScanTime) {
schedule(settings.deepScanTime, runDeepVerificationScan)
if (debugEnabled()) log.debug "Deep verification scan scheduled for ${settings.deepScanTime}"
}
}
def scheduleScanInterval() {
unschedule("scanAllDevices")
def intervalStr = settings?.scanInterval ?: "3"
def cronExpr = ""
switch (intervalStr) {
case "0.5": cronExpr = "0 */30 * * * ?"; break
case "1": cronExpr = "0 0 * * * ?"; break
case "3": cronExpr = "0 0 */3 * * ?"; break
case "6": cronExpr = "0 0 */6 * * ?"; break
default: cronExpr = "0 0 */3 * * ?"; break
}
schedule(cronExpr, scanAllDevices)
}
def reportScheduler() {
switch (reportFrequency) {
case "daily": scheduledSummary(); break
case "every2": if (shouldRunEveryXDays(2)) scheduledSummary(); break
case "every3": if (shouldRunEveryXDays(3)) scheduledSummary(); break
case "weekly": if (shouldRunWeekly()) scheduledSummary(); break
}
}
def shouldRunEveryXDays(daysInterval) {
def today = new Date().clearTime()
def lastRun = state.lastReportRun ? new Date(state.lastReportRun).clearTime() : null
if (!lastRun) { state.lastReportRun = now(); return true }
def diff = (today.time - lastRun.time) / (1000 * 60 * 60 * 24)
if (diff >= daysInterval) { state.lastReportRun = now(); return true }
return false
}
def shouldRunWeekly() {
def today = new Date()
def lastRun = state.lastReportRun ? new Date(state.lastReportRun) : null
if (!lastRun) { state.lastReportRun = now(); return true }
if (today.format("u") == "1") {
def diff = (today.time - lastRun.time) / (1000 * 60 * 60 * 24)
if (diff >= 7) { state.lastReportRun = now(); return true }
}
return false
}
// ============================================================
// ===================== DEEP VERIFICATION SCAN ==============
// ============================================================
def runDeepVerificationScan() {
def devList = getAllMonitoredDevices().findAll { getProtocol(it) != "Unknown" }
if (!devList) return
def targets = devList.findAll { getPingStatus(it.id) in ["declared", "unknown"] }
log.info "Device Health Monitor: deep verification scan starting — ${targets.size()} device(s) to verify"
if (targets.size() == 0) {
log.info "Device Health Monitor: deep verification scan — nothing to verify (all devices already Verified or Unverifiable)"
return
}
def totalDevices = targets.size()
def batchSize = totalDevices > 200 ? 25 : 40
def groups = targets.collate(batchSize)
def totalGroups = groups.size()
log.info "Device Health Monitor: deep scan — ${totalGroups} batch(es) of ${batchSize}"
state.deepScanQueue = groups.collect { group -> group.collect { it.id } }
state.deepScanTotal = totalGroups
state.deepScanCurrent = 0
processDeepScanGroup()
def actualDelay = ((totalGroups - 1) * 2) + 10
runIn(actualDelay, "finalizeDeepScan", [overwrite: false])
}
def processDeepScanGroup(data = null) {
def queue = state.deepScanQueue ?: []
if (queue.isEmpty()) return
def deviceIds = queue.remove(0)
state.deepScanQueue = queue
state.deepScanCurrent = (state.deepScanCurrent ?: 0) + 1
def groupNum = state.deepScanCurrent
def totalGroups = state.deepScanTotal ?: 0
def allDevs = getAllMonitoredDevices()
log.info "Device Health Monitor: deep scan batch ${groupNum}/${totalGroups} — pinging ${deviceIds.size()} device(s)"
if (!queue.isEmpty()) {
runIn(2, "processDeepScanGroup", [overwrite: false])
}
deviceIds.each { devId ->
def device = allDevs.find { it.id == devId }
if (!device) return
def capMapD = state.deviceCapabilities ?: [:]
def capKeyD = devId as String
def capDataD = capMapD[capKeyD] ?: [:]
def protocol = getProtocol(device)
def isVirtual = protocol in ["Virtual", "Hub Variable"]
if (isVirtual) {
capDataD.pingWorks = false
capDataD.pingFailed = (capDataD.pingFailed ?: 0) + 1
} else if (isHueDevice(device)) {
def bridge = findHueBridge()
if (bridge) {
try { bridge.refresh(); capDataD.pingAttempted = true; capDataD.lastPingAttempt = now() }
catch (e) { capDataD.pingWorks = false; capDataD.pingFailed = (capDataD.pingFailed ?: 0) + 1 }
} else {
capDataD.pingWorks = false
}
} else {
def hasRefresh = false
def hasPing = false
def hasCustomRefresh = false
try { hasRefresh = device.hasCapability("Refresh") } catch (e) {}
try { hasPing = device.hasCapability("Ping") } catch (e) {}
try { hasCustomRefresh = device.hasCommand("forceRefresh") || device.hasCommand("refresh") } catch (e) {}
if (hasRefresh) {
try { device.refresh(); capDataD.pingAttempted = true; capDataD.lastPingAttempt = now() }
catch (e) { capDataD.pingWorks = false; capDataD.pingFailed = (capDataD.pingFailed ?: 0) + 1 }
} else if (hasPing) {
try { device.ping(); capDataD.pingAttempted = true; capDataD.lastPingAttempt = now() }
catch (e) { capDataD.pingWorks = false; capDataD.pingFailed = (capDataD.pingFailed ?: 0) + 1 }
} else if (hasCustomRefresh) {
try {
if (device.hasCommand("forceRefresh")) device.forceRefresh()
else device.refresh()
capDataD.pingAttempted = true; capDataD.lastPingAttempt = now()
} catch (e) { capDataD.pingWorks = false; capDataD.pingFailed = (capDataD.pingFailed ?: 0) + 1 }
} else {
capDataD.pingWorks = false
capDataD.pingFailed = (capDataD.pingFailed ?: 0) + 1
}
}
capMapD[capKeyD] = capDataD
state.deviceCapabilities = capMapD
}
}
def finalizeDeepScan() {
def devList = getAllMonitoredDevices().findAll { getProtocol(it) != "Unknown" }
def verified = devList.count { getPingStatus(it.id) == "verified" }
def unverifiable = devList.count { getPingStatus(it.id) == "unverifiable" }
def declared = devList.count { getPingStatus(it.id) == "declared" }
state.deepScanResult = [
ranAt: now(),
verified: verified,
unverifiable: unverifiable,
declared: declared
]
app.updateSetting("enableDeepScan", [value: false, type: "bool"])
unschedule("runDeepVerificationScan")
log.info "Device Health Monitor: deep verification scan complete — ${verified} verified, ${unverifiable} unverifiable, ${declared} still declared"
runIn(30, "scanAllDevices", [overwrite: true])
log.info "Device Health Monitor: running scan to check for ping responses — waiting 30s for device responses"
}
// ============================================================
// ===================== SCAN — BATCHED ======================
// ============================================================
def scanAllDevices() {
def devList = getAllMonitoredDevices().findAll { getProtocol(it) != "Unknown" }
if (!devList) return
def nowMs = new Date().time
if (state.isScanning && state.scanStartTime && (nowMs - state.scanStartTime > 120000)) {
log.warn "Device Health Monitor: previous scan appears stuck — resetting."
state.isScanning = false
state.scanQueue = []
state.tempResults = []
}
if (state.isScanning) {
if (debugEnabled()) log.debug "Scan already in progress — skipping duplicate request."
return
}
log.info "Device Health Monitor: scan started — ${devList.size()} device(s) queued"
state.isScanning = true
state.scanStartTime = nowMs
state.tempResults = []
state.scanQueue = devList.collect { it.id }
purgeOrphanedState(devList)
runIn(1, "processScanChunk")
}
def purgeOrphanedState(devList) {
def activeIds = devList.collect { it.id as String } as Set
["history", "health", "verifying", "stateHistory"].each { stateKey ->
def map = state[stateKey]
if (map instanceof Map) {
def stale = map.keySet().findAll { !(it in activeIds) }
if (stale) {
stale.each { map.remove(it) }
state[stateKey] = map
if (debugEnabled()) log.debug "Purged ${stale.size()} orphaned ${stateKey} entr${stale.size() == 1 ? 'y' : 'ies'}"
}
}
}
if (state.snoozed instanceof Map) {
def snoozedCopy = state.snoozed
def staleSnoozed = snoozedCopy.keySet().findAll { !(it in activeIds) }
if (staleSnoozed) {
staleSnoozed.each { snoozedCopy.remove(it) }
state.snoozed = snoozedCopy
}
}
}
def processScanChunk() {
if (!state.isScanning) return
def queue = state.scanQueue ?: []
if (queue.size() == 0) {
finalizeScan()
return
}
def totalDevices = getAllMonitoredDevices().size()
def chunkSize = totalDevices > 200 ? 25 : 40
def chunk = queue.take(chunkSize)
def remaining = queue.drop(chunkSize)
state.scanQueue = remaining
def batchNum = Math.ceil(totalDevices / chunkSize).toInteger() - Math.ceil(remaining.size() / chunkSize).toInteger()
log.info "Device Health Monitor: scanning batch ${batchNum} — ${chunk.size()} devices (${remaining.size()} remaining)"
def allDevs = getAllMonitoredDevices()
def intervalStr = settings?.scanInterval ?: "3"
def intervalMinutes = (intervalStr.toFloat() * 60).toInteger()
def minGate = Math.min(intervalMinutes * 0.5, 30.0)
def nowMs = new Date().time
chunk.each { devId ->
def device = allDevs.find { it.id == devId }
if (!device) return
try {
def id = device.id
def data = state.history[id]
def protocol = getProtocol(device)
def filtered = usesFilteredSampling(protocol)
def lastActivity = device.getLastActivity()
def lastSeen = (lastActivity ? safeTime(lastActivity) : null) ?: now()
try {
def stateDate = device.currentStates?.collect { safeTime(it.date) }?.findAll { it }?.max()
if (stateDate && stateDate > lastSeen) lastSeen = stateDate
} catch (e) {
if (debugEnabled()) log.debug "currentStates date check error for ${device.displayName}: ${e.message}"
}
def capMap = state.deviceCapabilities ?: [:]
def capKey = id as String
def capData = capMap[capKey] ?: [:]
if (isHueDevice(device)) {
if (debugEnabled()) log.debug "DHM capability scan: ${device.displayName} detected as Hue — marking declared"
capData.declared = true
capData.declaredRefresh = true
if (capData.pingWorks == false) { capData.pingWorks = null; capData.pingFailed = 0 }
} else if (isKonnectedDevice(device)) {
if (debugEnabled()) log.debug "DHM capability scan: ${device.displayName} detected as Konnected child (DNI: ${device.deviceNetworkId}) — marking declared"
capData.declared = true
capData.declaredRefresh = true
if (capData.pingWorks == false) { capData.pingWorks = null; capData.pingFailed = 0 }
} else {
def declaredRefresh = false
def declaredPing = false
def hasCustomRefresh = false
def capCheckOk = false
try { declaredRefresh = device.hasCapability("Refresh"); capCheckOk = true } catch (e) {}
try { declaredPing = device.hasCapability("Ping"); capCheckOk = true } catch (e) {}
try { hasCustomRefresh = device.hasCommand("forceRefresh") ||
device.hasCommand("refresh") ; capCheckOk = true } catch (e) {}
capData.declaredRefresh = declaredRefresh || hasCustomRefresh
capData.declaredPing = declaredPing
capData.declared = declaredRefresh || declaredPing || hasCustomRefresh
capData.customRefreshCmd = hasCustomRefresh && !declaredRefresh ? "forceRefresh" : null
if (capCheckOk && !capData.declared && capData.pingWorks == null) {
capData.pingWorks = false
capData.pingFailed = 0
}
}
if (!capData.containsKey("pingFailed")) capData.pingFailed = 0
capMap[capKey] = capData
state.deviceCapabilities = capMap
if (!data) {
state.history[id] = [
lastSeen: lastSeen,
samples: [],
avgInterval: null,
userInterval: null,
protocol: protocol
]
state.health[id] = "Pending"
} else {
def prevLastSeen = data.lastSeen ?: lastSeen
if (lastSeen > prevLastSeen) {
def capMapRec = state.deviceCapabilities ?: [:]
def capKeyRec = id as String
def capDataRec = capMapRec[capKeyRec] ?: [:]
if (capDataRec.pingAttempted == true) {
capDataRec.pingWorks = true
capDataRec.pingFailed = 0
capDataRec.pingAttempted = false
capMapRec[capKeyRec] = capDataRec
state.deviceCapabilities = capMapRec
if (debugEnabled()) log.debug "${device.displayName}: ping confirmed working — device responded after verification attempt"
}
def elapsed = (lastSeen - prevLastSeen) / (1000 * 60)
data.lastSeen = lastSeen
if (elapsed >= minGate) {
def recordSample = true
if (filtered) {
recordSample = elapsed <= (intervalMinutes * 1.5)
}
if (recordSample) {
def alpha = 0.15
def prevSmooth = (data.samples && data.samples.size() > 0) ? data.samples[-1] : elapsed
def smoothed = alpha * elapsed + (1 - alpha) * prevSmooth
data.samples << smoothed
if (data.samples.size() > 20) data.samples.remove(0)
if (data.samples.size() >= 3) {
data.avgInterval = data.samples.sum() / data.samples.size()
}
}
}
}
data.protocol = protocol
state.history[id] = data
updateHealth(device)
}
updateStateTracking(device)
} catch (e) {
log.warn "Scan failed for ${device.displayName}: ${e.message}"
}
}
if (state.scanQueue.size() > 0) {
runIn(2, "processScanChunk")
} else {
runIn(1, "finalizeScan")
}
}
def finalizeScan() {
if (!state.isScanning) return
state.isScanning = false
state.scanStartTime = null
state.tempResults = []
state.scanQueue = []
log.info "Device Health Monitor: scan complete — all devices processed"
}
// ============================================================
// ===================== HEALTH SCORING ======================
// ============================================================
def updateHealth(device) {
def id = device.id
def data = state.history[id]
if (!data) return
def samples = data.samples?.size() ?: 0
if (samples < 3) {
state.health[id] = "Pending"
state.verifying?.remove(id)
return
}
def offlineThreshold = ((settings?.offlineThresholdHours ?: 168) * 60).toDouble()
def minutesSinceLastSeen = (now() - (data.lastSeen ?: now())) / (1000 * 60)
if (minutesSinceLastSeen >= offlineThreshold) {
state.health[id] = "Offline"
} else {
// v1.5.3: Protocol-aware minimum baseline floor
// Prevents burst-usage devices (Apple TV, media players, LAN devices) from
// learning an unrealistically short baseline during active periods and then
// falsely scoring Poor when they go quiet. Zigbee/Z-Wave floors stay tight
// so real mesh failures are still caught quickly.
def protocol = getProtocol(device)
def minBaseline = 30.0 // default — Zigbee / Z-Wave (30 min)
switch (protocol) {
case "LAN":
case "Hub Mesh":
case "Hub Mesh (Zigbee)":
case "Hub Mesh (Z-Wave)":
case "Hub Mesh (Matter)":
minBaseline = 480.0 // 8 hours — LAN/media devices
break
case "Matter":
minBaseline = 120.0 // 2 hours
break
case "Virtual":
case "Hub Variable":
minBaseline = 1440.0 // 24 hours
break
}
def baseline = Math.max(
(data.userInterval ?: data.avgInterval ?: 60).toDouble(),
minBaseline
)
// v1.5.3: Loosened thresholds — give burst-use devices (locks, lights,
// switches, media devices, door sensors) real breathing room before
// notifications fire. Devices need to go truly quiet before reaching Poor.
def ratio = minutesSinceLastSeen / baseline
if (ratio <= 1.5) state.health[id] = "Excellent"
else if (ratio <= 3.0) state.health[id] = "Good"
else if (ratio <= 6.0) state.health[id] = "Fair"
else state.health[id] = "Poor"
}
def currentHealth = state.health[id]
// v1.5.3: Pingable hold-at-Fair gate
// When a device would enter Poor for the first time and supports refresh/ping,
// hold it at Fair for one scan cycle while a verification ping is sent.
// If it responds → recovers automatically, never reaches Poor or fires a notification.
// If it doesn't respond → next scan confirms it as genuinely Poor.
if (currentHealth == "Poor") {
def prevH = state.prevHealth?.get(id as String)
if (prevH != "Poor" && prevH != "Offline") {
def capChk = state.deviceCapabilities?.get(id as String) ?: [:]
def isPingable = capChk.pingWorks == true ||
capChk.declared == true ||
isHueDevice(device) ||
isKonnectedDevice(device)
if (isPingable) {
state.health[id] = "Fair"
currentHealth = "Fair"
if (debugEnabled()) log.debug "${device.displayName}: first Poor entry — holding at Fair pending verification ping"
}
}
}
def prevHealth = state.prevHealth?.get(id as String)
if (currentHealth in ["Poor", "Offline"] && !(prevHealth in ["Poor", "Offline"])) {
def dropMap = state.dropHistory ?: [:]
def drops = dropMap[id as String] ?: []
drops << now()
drops = drops.findAll { now() - it < 86400000 }
dropMap[id as String] = drops
state.dropHistory = dropMap
}
// v1.5.2: Auto-reset verification status on health recovery
// When a device recovers from Poor/Offline back to Good/Excellent,
// clear pingWorks so it gets a fresh verification attempt next time it drops.
// This prevents devices from being permanently stuck as Unverifiable after
// a single bad attempt — seasonal/sporadic devices benefit most from this.
if (currentHealth in ["Good", "Excellent"] && prevHealth in ["Poor", "Offline"]) {
def capMapR = state.deviceCapabilities ?: [:]
def capKeyR = id as String
def capDataR = capMapR[capKeyR] ?: [:]
if (capDataR.pingWorks == false) {
capDataR.pingWorks = null
capDataR.pingFailed = 0
capMapR[capKeyR] = capDataR
state.deviceCapabilities = capMapR
if (debugEnabled()) log.debug "${device.displayName}: health recovered to ${currentHealth} — verification status reset for fresh re-evaluation"
}
}
if (!state.prevHealth) state.prevHealth = [:]
def prevMap = state.prevHealth
prevMap[id as String] = currentHealth
state.prevHealth = prevMap
if (!(currentHealth in ["Poor", "Offline"])) {
state.verifying?.remove(id)
return
}
if (state.verifying == null) state.verifying = [:]
if (state.verifying[id]) {
state.verifying.remove(id)
return
}
if (getStateVerified(id as String)) {
state.verifying[id] = "state_verified"
log.info "Device Health Monitor: ${currentHealth} — ${device.displayName} self-verified via state change event (no ping needed)"
if (data?.samples?.size() > 0) {
data.samples.remove(data.samples.size() - 1)
if (data.samples.size() >= 3) {
data.avgInterval = data.samples.sum() / data.samples.size()
}
state.history[id] = data
}
return
}
def protocol = getProtocol(device)
def isVirtual = protocol in ["Virtual", "Hub Variable"]
def hasRefresh = false
def hasPing = false
def verifyMethod = ""
if (isVirtual) {
verifyMethod = "virtual"
} else if (isHueDevice(device)) {
def bridge = findHueBridge()
if (bridge) {
try {
bridge.refresh()
verifyMethod = "hue_bridge"
markChildrenPingAttempted(bridge.id as String)
}
catch (e) { verifyMethod = "hue_bridge_failed" }
} else {
verifyMethod = "hue_no_bridge"
}
} else if (isKonnectedDevice(device)) {
def panel = findKonnectedPanel(device)
if (panel) {
try {
panel.refresh()
verifyMethod = "konnected_panel"
markChildrenPingAttempted(panel.id as String)
}
catch (e) { verifyMethod = "konnected_panel_failed" }
} else {
verifyMethod = "konnected_no_panel"
}
} else {
try { hasRefresh = device.hasCapability("Refresh") } catch (e) { }
try { hasPing = device.hasCapability("Ping") } catch (e) { }
def hasCustomRefresh = false
try { hasCustomRefresh = device.hasCommand("forceRefresh") || device.hasCommand("refresh") } catch (e) {}
if (hasRefresh) {
try { device.refresh(); verifyMethod = "refresh" }
catch (e) { verifyMethod = "failed" }
} else if (hasPing) {
try { device.ping(); verifyMethod = "ping" }
catch (e) { verifyMethod = "failed" }
} else if (hasCustomRefresh) {
try {
if (device.hasCommand("forceRefresh")) { device.forceRefresh(); verifyMethod = "refresh" }
else { device.refresh(); verifyMethod = "refresh" }
} catch (e) { verifyMethod = "failed" }
} else {
verifyMethod = "none"
}
}
state.verifying[id] = verifyMethod
if (verifyMethod in ["refresh", "ping"]) {
log.info "Device Health Monitor: ${currentHealth} — sent ${verifyMethod} to ${device.displayName}"
} else if (verifyMethod in ["none", "virtual", "hue_no_bridge", "failed"]) {
if (debugEnabled()) log.debug "Device Health Monitor: ${currentHealth} — cannot verify ${device.displayName} (${verifyMethod})"
}
def capMapH = state.deviceCapabilities ?: [:]
def capKeyH = id as String
def capDataH = capMapH[capKeyH] ?: [:]
if (verifyMethod in ["refresh", "ping", "hue_bridge"]) {
capDataH.lastPingAttempt = now()
capDataH.pingAttempted = true
} else if (verifyMethod in ["none", "virtual", "hue_no_bridge", "hue_bridge_failed", "failed"]) {
capDataH.pingWorks = false
capDataH.pingFailed = (capDataH.pingFailed ?: 0) + 1
}
capMapH[capKeyH] = capDataH
state.deviceCapabilities = capMapH
if (currentHealth == "Offline" &&
isLowActivity(id as String) &&
verifyMethod in ["none", "virtual", "hue_no_bridge", "hue_bridge_failed", "failed"]) {
state.health[id] = "Poor"
if (debugEnabled()) log.debug "${device.displayName}: Low activity + unverifiable — capped at Poor instead of Offline"
}
}
// ============================================================
// ===================== HEALTH DISPLAY ======================
// ============================================================
def getHealthDisplay(device) {
def h = state.health?.get(device.id) ?: "Pending"
def samples = state.history?.get(device.id)?.samples?.size() ?: 0
def snoozed = isDeviceSnoozed(device.id as String)
if (snoozed) {
def remaining = formatSnoozeRemaining(device.id as String)
return "😴 Snoozed (${remaining})"
}
if (h == "Pending") {
return "⏳ Pending (${samples}/3 samples)"
}
if (h in ["Poor", "Offline"]) {
def baseDisplay = h == "Poor"
? "🔴 Poor"
: "💀 Offline"
def lowActivity = isLowActivity(device.id as String)
def repeatDrops = isRepeatDrops(device.id as String)
def tagSuffix = ""
if (repeatDrops) tagSuffix = " 🔄 Repeat Drops"
else if (lowActivity) tagSuffix = " ℹ️ Low Activity Device"
def verifyMethod = state.verifying?.get(device.id)
if (verifyMethod == null) return "${baseDisplay}${tagSuffix}"
switch (verifyMethod) {
case "state_verified": return "${baseDisplay}${tagSuffix} ✅ State verified — device active via event"
case "refresh": return "${baseDisplay}${tagSuffix} 🔄 Verifying... (refresh sent)"
case "ping": return "${baseDisplay}${tagSuffix} 🔄 Verifying... (ping sent)"
case "hue_bridge": return "${baseDisplay}${tagSuffix} 🔄 Verifying... (Hue Bridge refresh sent)"
case "hue_no_bridge": return "${baseDisplay}${tagSuffix} ⚠ Cannot verify — add Hue Bridge to monitored devices"
case "hue_bridge_failed": return "${baseDisplay}${tagSuffix} ⚠ Hue Bridge refresh failed"
case "konnected_panel": return "${baseDisplay}${tagSuffix} 🔄 Verifying... (Konnected Panel refresh sent)"
case "konnected_no_panel": return "${baseDisplay}${tagSuffix} ⚠ Cannot verify — add Konnected Alarm Panel to monitored devices"
case "konnected_panel_failed": return "${baseDisplay}${tagSuffix} ⚠ Konnected Panel refresh failed"
case "virtual": return "${baseDisplay}${tagSuffix} ⚠ Cannot verify — virtual device"
case "none": return "${baseDisplay}${tagSuffix} ⚠ Cannot verify — device does not support ping or refresh"
case "failed": return "${baseDisplay}${tagSuffix} ⚠ Verification attempted but command failed"
default: return "${baseDisplay}${tagSuffix}"
}
}
switch (h) {
case "Excellent":
case "Good":
case "Fair":
def lowActivity = isLowActivity(device.id as String)
def extStateTag = getExtendedStateTag(device)
def lowSuffix = (lowActivity && !extStateTag) ? " ℹ️ Low Activity Device" : ""
def healthEmoji = h == "Fair" ? "🟠" : "🟢"
return "${healthEmoji} ${h}${extStateTag}${lowSuffix}"
default: return "${h}"
}
}
def getHealthEmoji(h) {
switch (h) {
case "Excellent": return "🟢"
case "Good": return "🟢"
case "Fair": return "🟠"
case "Poor": return "🔴"
case "Offline": return "💀"
default: return "⏳"
}
}
// ============================================================
// ===================== SAFE HELPERS ========================
// ============================================================
def safeTime(ts) {
if (ts == null) return null
if (ts instanceof Number) return ts
try {
def t = ts?.time
if (t instanceof Number) return t
if (t?.toString()?.isNumber()) return t.toString().toLong()
return null
} catch (e) {
return null
}
}
def formatTimeAgo(ts) {
if (!ts) return "N/A"
ts = safeTime(ts)
def diffMs = now() - ts
def mins = (diffMs / (1000 * 60)).toInteger()
def hours = (diffMs / (1000 * 60 * 60)).toInteger()
def days = (diffMs / (1000 * 60 * 60 * 24)).toInteger()
def weeks = (days / 7).toInteger()
def months = (days / 30).toInteger()
if (months >= 1) return "${months}mo ago"
if (weeks >= 1) return "${weeks}w ago"
if (days >= 1) return "${days}d ago"
if (hours >= 1) return "${hours}h ago"
return "${mins}m ago"
}
def formatInterval(minutes) {
if (!minutes) return "—"
def m = minutes.toInteger()
if (m < 60) return "${m}m"
if (m < 1440) return "${(m / 60).toInteger()}h ${m % 60}m"
return "${(m / 1440).toInteger()}d ${((m % 1440) / 60).toInteger()}h"
}
def formatStateDisplay(stateInfo) {
if (!stateInfo) return "—"
def label = stateInfo.label
def color = stateInfo.color
switch (color) {
case "#c62828": return "${label}"
case "#e65100": return "${label}"
case "#1565c0": return "${label}"
case "#8b5cf6": return "${label}"
case "#16a34a": return "${label}"
default: return "${label}"
}
}
def formatStateDisplayInput(stateInfo) {
if (!stateInfo) return "—"
def label = stateInfo.label
def color = stateInfo.color
switch (color) {
case "#c62828": return "${label}"
case "#e65100": return "${label}"
case "#1565c0": return "${label}"
case "#8b5cf6": return "${label}"
case "#16a34a": return "${label}"
default: return "${label}"
}
}
def formatStateDisplayOverride(stateInfo) {
if (!stateInfo) return "—"
def label = stateInfo.label
def color = stateInfo.color
switch (color) {
case "#c62828": return "[${label}]"
case "#e65100": return "[${label}]"
case "#1565c0": return "[${label}]"
case "#8b5cf6": return "[${label}]"
case "#16a34a": return "[${label}]"
default: return "[${label}]"
}
}
// ============================================================
// ===================== OAUTH PORTAL HELPERS ================
// ============================================================
def getPortalRedirectHtml(delayMs, msgText) {
return "" +
"" +
"" +
"
⚠ Device links are accessible on your local network only.
" : ""}
${table}
"""
}
section("🔄 Reset Device History", hideable: true, hidden: true) {
paragraph "Reset check-in history for specific devices."
href(name: "toResetHistory", page: "resetHistoryPage", title: "🔄 Reset Device History")
}
}
}
// ============================================================
// ===================== HUB MESH SUMMARY PAGE ===============
// ============================================================
def hubMeshSummaryPage() {
dynamicPage(name: "hubMeshSummaryPage", title: "🔗 Hub Mesh Overview", install: false) {
section("") {
def devList = getAllMonitoredDevices().findAll { p -> getProtocol(p).startsWith("Hub Mesh") }
if (!devList) { paragraph "No Hub Mesh devices found in your monitored device list."; return }
def groups = buildHubMeshSummary()
def hubIp = location?.hub?.localIP ?: ""
paragraph rawHtml: true, "
ℹ️ Source hub detection is not supported on current Hubitat firmware. All Hub Mesh devices show as \"Remote Hub\" — this does not affect health monitoring.
"
if (hubIp) paragraph "⚠ Device links are accessible on your local network only."
paragraph "
${table}
"
}
}
section("Unverifiable Devices — ${unverCount} device(s)", hideable: true, hidden: unverCount == 0) {
if (unverCount == 0) {
paragraph "✅ All devices support ping or refresh verification."
} else {
paragraph "
" +
"These devices cannot be pinged or refreshed. If they go Offline the app cannot confirm whether they are truly unreachable. " +
"Verification status resets automatically when a device recovers to Good or Excellent — so devices that were previously unverifiable will get a fresh attempt next time they drop.
" +
"✅ Verified (${verified}) — confirmed responds to ping or refresh after going Poor/Offline " +
"🔄 Declared (${declared}) — capability declared by driver, not yet tested under real conditions " +
"⚠ Unverifiable (${unverCount}) — no capability or command confirmed non-functional " +
"❓ Unknown (${unknownCount}) — not yet scanned
" +
"Verification status resets automatically on health recovery so devices always get a fresh attempt. " +
"Run Deep Verification Scan to force re-evaluation of all declared devices.
" +
"🔀 Protocol Overrides " +
"Some Hub Mesh linked devices and LAN devices cannot be automatically identified. Set the correct protocol manually. Set back to Auto-detect to restore automatic detection.
" +
"Note: A new check-in sample is only recorded if at least ${minGate} minutes have passed since the last recorded activity."
}
}
}
// ============================================================
// ===================== RESET HISTORY PAGE ==================
// ============================================================
def resetHistoryPage() {
app.removeSetting("resetHistoryDevices")
app.updateSetting("resetHistoryConfirm", [value: false, type: "bool"])
def devList = getAllMonitoredDevices().findAll { getProtocol(it) != "Unknown" }.sort { a, b -> a.displayName.trim() <=> b.displayName.trim() }
dynamicPage(name: "resetHistoryPage", title: "Reset Device History", install: false) {
section("Select Devices to Reset") {
if (!devList || devList.size() == 0) {
paragraph "No devices available."
} else {
paragraph "Select one or more devices to reset. Their check-in history and learned baseline will be cleared."
input "resetHistoryDevices", "enum",
title: "Select devices to reset",
options: devList.collectEntries { [(it.id): "${it.displayName} (${state.health?.get(it.id) ?: 'Pending'})"] }.sort { a, b -> a.value <=> b.value },
multiple: true, required: false
}
}
section("Confirm Reset") {
input "resetHistoryConfirm", "bool", title: "Confirm — clear history for selected devices", defaultValue: false
}
section() { href(name: "toResetConfirm", page: "resetHistoryConfirmPage", title: "Submit Reset") }
}
}
def resetHistoryConfirmPage() {
def devList = getAllMonitoredDevices()
dynamicPage(name: "resetHistoryConfirmPage", title: "Reset Device History", install: false) {
section("Result") {
if (!resetHistoryConfirm) {
paragraph "Reset cancelled — confirm checkbox was not checked."
} else if (!resetHistoryDevices) {
paragraph "No devices selected."
} else {
def successCount = 0
def resetNames = []
resetHistoryDevices.each { deviceId ->
def device = devList.find { it.id == deviceId }
if (device) {
def h = state.history ?: [:]
h[device.id] = [
lastSeen: now(),
samples: [],
avgInterval: null,
userInterval: state.history?.get(device.id)?.userInterval,
protocol: getProtocol(device)
]
state.history = h
def health = state.health ?: [:]
health[device.id] = "Pending"
state.health = health
def sh = state.stateHistory ?: [:]
sh.remove(device.id)
state.stateHistory = sh
resetNames << device.displayName
successCount++
}
}
if (successCount > 0) {
paragraph "✅ History reset for ${successCount} device(s): ${resetNames.join(', ')}."
} else {
paragraph "No valid devices found."
}
}
}
}
}
// ============================================================
// ===================== SEND NOTIFICATION PAGE ==============
// ============================================================
def sendNotificationPage() {
dynamicPage(name: "sendNotificationPage", title: "Send Notification", install: false) {
def devList = getAllMonitoredDevices().findAll { getProtocol(it) != "Unknown" }
def hasDevices = devList.size() > 0
def hasTargets = (settings?.notifyDevices?.size() ?: 0) > 0 ||
(settings?.pushoverDevices?.size() ?: 0) > 0 ||
(settings?.enablePush == true)
def notifyOn = settings?.enablePush != false
if (!hasDevices) { section("Cannot Send") { paragraph "⚠️ No monitored devices are selected." }; return }
if (!notifyOn) { section("Cannot Send") { paragraph "⚠️ Notifications are turned off." }; return }
if (!hasTargets) { section("Cannot Send") { paragraph "⚠️ No notification devices configured." }; return }
section("Confirm") {
paragraph "This will send a device health summary notification now."
input "sendNowConfirm", "bool", title: "✅ Confirm — send the notification", defaultValue: false, submitOnChange: true
}
if (settings?.sendNowConfirm) {
section("Result") {
scheduledSummary()
app.updateSetting("sendNowConfirm", [value: false, type: "bool"])
def sentTo = []
if (settings?.notifyDevices) sentTo.addAll(settings.notifyDevices.collect { it.displayName })
if (settings?.pushoverDevices) sentTo.addAll(settings.pushoverDevices.collect { "${it.displayName} (Pushover)" })
paragraph sentTo ? "✅ Notification sent to:\n" + sentTo.collect { "• ${it}" }.join("\n") : "✅ Notification sent via hub push."
}
}
}
}
// ============================================================
// ===================== SCHEDULED SUMMARY ===================
// ============================================================
def scheduledSummary() {
if (!isModeOK()) return
def devList = getAllMonitoredDevices().findAll { getProtocol(it) != "Unknown" }
if (!devList) return
def usePushover = (settings?.enablePushover == true && settings?.pushoverPrefix?.trim())
def prefix = ""
def postfix = ""
if (usePushover) {
def tags = settings.pushoverPrefix.trim()
def priorityMatch = tags =~ /^(\[[EHLNS]\])(.*)/
if (priorityMatch) { prefix = priorityMatch[0][1]; postfix = priorityMatch[0][2].trim() }
else { postfix = tags }
}
def body = "${prefix}📡 Device Health Summary\n"
def sections = [
"Offline": [emoji: "💀", enabled: settings?.notifyOffline != false, list: []],
"Poor": [emoji: "🔴", enabled: settings?.notifyPoor != false, list: []],
"Fair": [emoji: "🟠", enabled: settings?.notifyFair != false, list: []],
"Good": [emoji: "🟢", enabled: settings?.notifyGood ?: false, list: []],
"Excellent": [emoji: "🟢", enabled: settings?.notifyExcellent ?: false, list: []]
]
devList.each { device ->
if (!isDeviceSnoozed(device.id as String)) {
def h = state.health?.get(device.id) ?: "Pending"
if (sections.containsKey(h)) {
def stateInfo = getCurrentStateDisplay(device)
def stateStr = stateInfo ? " [${stateInfo.label}]" : ""
def lastStr = state.history?.get(device.id)?.lastSeen
? ", last seen ${formatTimeAgo(state.history[device.id].lastSeen)}" : ""
sections[h].list << "${device.displayName.trim()}${stateStr}${lastStr}"
}
}
}
if (settings?.suppressEmptyReport) {
def hasContent = sections.any { h, data -> data.enabled && data.list }
if (!hasContent) return
}
sections.each { health, data ->
if (data.enabled) {
body += "\n${data.emoji} ${health}:\n"
if (data.list) { data.list.each { name -> body += "• ${name}\n" } }
else { body += "None\n" }
}
}
def pushoverBody = body
def plainBody = body
if (postfix) pushoverBody += "${postfix}\n"
if (settings?.enablePush) sendPush(pushoverBody)
if (settings?.pushoverDevices) settings.pushoverDevices.each { it.deviceNotification(pushoverBody) }
if (settings?.notifyDevices) notifyDevices.each { it.deviceNotification(plainBody) }
}
// ============================================================
// ===================== INFO PAGE ===========================
// ============================================================
def infoPage(Map params = [:]) {
dynamicPage(name: "infoPage", title: "App Guide & Reference", install: false) {
section("🌐 Web Portal") {
paragraph rawHtml: true, "
" +
"The Device Health Portal is a browser-accessible dashboard available from any device — phone, tablet, or desktop.
" +
"SPA Architecture: The portal shell loads instantly, then fetches device data asynchronously. Even with 200+ devices the portal opens immediately.
" +
"How to enable: Go to Apps Code → Device Health Monitor → OAuth (top right) → Enable → Update. " +
"Then open the app and tap Done. Cloud and Local URLs appear at the top of the main page.
" +
"What it shows: All devices with health rating, protocol, current state, last check-in, avg check-in, location, and description. " +
"Summary cards show Offline, Poor, Fair, Healthy, and Total counts.
" +
"Group by: Toggle between By Protocol, By Health, and By Location using the dropdown on the portal.
" +
"Edit from portal: Tap any device card to update location and description without opening the Hubitat app.
" +
"Force Scan: The Force Scan button triggers an immediate batch scan from the browser.
" +
"Auto-refresh: The portal silently refreshes every 60 seconds.
" +
"Dashboard tile: Add a Link tile to your Hubitat dashboard and paste in the portal URL.
" +
"Devices are scanned in batches (40 per chunk, 25 for installs over 200 devices) with a 2-second pause between batches. " +
"Health scores update progressively as each batch completes.
" +
"Stuck scan protection: If a scan hasn't completed within 2 minutes it is automatically reset.
" +
"Assign rooms or locations to devices from the 🏷️ Location Assignment page. " +
"Locations are used for the Group by Location view on the portal and appear on each device card.
" +
"Use Bulk Apply to assign the same location to multiple devices at once. " +
"Individual assignments can also be set directly from the portal edit modal.
"
}
section("🔑 Health Ratings") {
paragraph rawHtml: true, "
" +
"
" +
"
Health
Meaning
" +
"
⏳ Pending (n/3 samples)
Learning — sample count shown inline until 3 are collected
" +
"
🟢 Excellent
Checking in within 1.5× of baseline
" +
"
🟢 Good
Checking in within 3× of baseline
" +
"
🟠 Fair
Checking in within 6× of baseline
" +
"
🔴 Poor
Checking in beyond 6× of baseline
" +
"
💀 Offline
No activity for configured threshold (default ${settings?.offlineThresholdHours ?: 168}h). Low activity unverifiable devices are capped at Poor.
" +
"
😴 Snoozed
Excluded from notifications for a set duration
" +
"
ℹ️ Low Activity
Monitored 7+ days with fewer than 3 samples — infrequently used device
" +
"
"
}
section("⏳ How Baselines Are Learned") {
paragraph rawHtml: true, "
" +
"The app learns each device's normal check-in pattern automatically — no configuration needed.
" +
"Sample collection: Each time a device checks in, the elapsed time since its last check-in is recorded as a smoothed sample.
" +
"Pending state: A device shows ⏳ Pending until 3 samples have been collected.
" +
"Minimum gate: A sample is only counted if at least half the scan interval has passed since the last recorded activity (capped at 30 minutes).
" +
"Sample window: Up to 20 samples are kept per device.
" +
"When a device first enters Poor or Offline the app attempts to verify it is still reachable:
" +
"1. State-change verification: If the device fired a state change event after its last recorded check-in and within the full offline threshold window, " +
"it is considered ✅ State verified — alive without needing a ping.
" +
"2. Refresh / Ping: If state-change verification is not available, the app sends refresh() or ping() to the device.
" +
"Pingable hold-at-Fair (v1.5.3): When a device enters Poor for the first time and supports refresh or ping, it is held at Fair for one scan cycle while a verification ping is sent. If the device responds it recovers automatically without ever reaching Poor. Only devices that fail to respond are promoted to Poor.
" +
"Auto-reset on recovery (v1.5.2): When a device recovers from Poor or Offline back to Good or Excellent, its verification status is automatically reset. " +
"This means devices are never permanently stuck as Unverifiable — they always get a fresh attempt next time they drop. " +
"Particularly useful for seasonal or sporadic devices that go quiet for extended periods.
" +
"Hue devices: Add your Hue Bridge to monitored devices — the app refreshes the Bridge when any Hue device goes Poor or Offline.
" +
"Konnected devices: Add your Konnected Alarm Panel to monitored devices — child sensors are verified by refreshing the panel.
"
}
section("💡 Tips for Best Results") {
paragraph rawHtml: true, "
" +
"• Enable OAuth in App Code to unlock the Web Portal " +
"• Scheduled devices (lights, irrigation) self-verify via state changes — no configuration needed " +
"• Verification status auto-resets on health recovery — no manual intervention needed for seasonal devices " +
"• Low activity devices that cannot be verified will show Poor instead of Offline — this is intentional " +
"• Assign locations in the 🏷️ Location Assignment page — enables room grouping in the portal " +
"• Add your Hue Bridge or CoCoHue Bridge to monitored devices for Hue verification support " +
"• After updating the app, run Force Scan to immediately update all health scores