/** * Device Health Monitor * Version: 1.5.3 * * Author: jdthomas24 */ definition( name: "Device Health Monitor", namespace: "jdthomas24", author: "jdthomas24", description: "Monitor device check-in health across Zigbee, Z-Wave, Matter, Hub Mesh, LAN, Virtual and Hub Variable — learns each device's normal pattern and alerts you when something goes quiet. Includes OAuth web portal, SPA dashboard, batch scanning, location grouping, and richer notifications.", category: "Convenience", importUrl: "https://raw.githubusercontent.com/jdthomas24/Hubitat-Apps-Drivers/refs/heads/main/Device%20Health%20Monitor/Raw%20Code/DeviceHealthMonitor.groovy", iconUrl: "", iconX2Url: "", version: "1.5.3", doNotFocus: true, oauth: true ) // ============================================================ // ===================== OAUTH MAPPINGS ====================== // ============================================================ mappings { path("/dashboard") { action: [GET: "serveDashboardPage"] } path("/data") { action: [GET: "serveDataEndpoint"] } path("/refresh") { action: [GET: "forceRefreshEndpoint"] } path("/updateDevice") { action: [GET: "updateDeviceEndpoint"] } } // ============================================================ // ===================== PREFERENCES ========================= // ============================================================ preferences { page(name: "mainPage") page(name: "activitySummaryPage") page(name: "problemDevicesPage") page(name: "sendNotificationPage") page(name: "forceScanPage") page(name: "resetHistoryPage") page(name: "resetHistoryConfirmPage") page(name: "snoozeManagePage") page(name: "protocolOverridePage") page(name: "hubMeshSummaryPage") page(name: "locationAssignPage") page(name: "infoPage") } // ============================================================ // ===================== LIFECYCLE =========================== // ============================================================ def installed() { if (debugEnabled()) log.debug "Device Health Monitor installed" applyCustomLabel() initialize() } def updated() { if (debugEnabled()) log.debug "Device Health Monitor updated" applyCustomLabel() unschedule() unsubscribe() if (settings?.enableSnooze == false) { state.snoozed = [:] if (debugEnabled()) log.debug "Snooze disabled — all active snoozes cleared" } initialize() runIn(1800, disableDebugLogging) } def appButtonHandler(btn) { if (btn == "btnRunDeepScan") { runDeepVerificationScan() } } def initialize() { if (debugEnabled()) log.debug "Device Health Monitor initializing" if (state.history == null) state.history = [:] if (state.health == null) state.health = [:] if (state.snoozed == null) state.snoozed = [:] if (state.verifying == null) state.verifying = [:] if (state.stateHistory == null) state.stateHistory = [:] if (state.scanQueue == null) state.scanQueue = [] if (state.tempResults == null) state.tempResults = [] if (state.isScanning == null) state.isScanning = false if (state.deviceCapabilities == null) state.deviceCapabilities = [:] if (state.deepScanResult == null) state.deepScanResult = [:] if (state.dropHistory == null) state.dropHistory = [:] if (!state.capabilitiesResetDone) { state.deviceCapabilities = [:] state.capabilitiesResetDone = true if (debugEnabled()) log.debug "Device Health Monitor: reset deviceCapabilities — will rebuild on next scan" } if (state.deviceLocations == null) state.deviceLocations = [:] getAllMonitoredDevices()?.each { device -> def existing = settings["loc_${device.id}"] if (existing && !state.deviceLocations?.containsKey(device.id as String)) { if (!state.deviceLocations) state.deviceLocations = [:] state.deviceLocations[device.id as String] = existing } } state.deviceLocations = state.deviceLocations if (!state.accessToken) { try { createAccessToken() } catch (e) { log.error "Device Health Monitor: OAuth is not enabled. Please enable OAuth in the App Code screen." } } scheduleScanInterval() scheduleReportFrequency() scheduleDeepVerificationScan() if (debugEnabled()) log.debug "Monitoring ${getAllMonitoredDevices().findAll { getProtocol(it) != 'Unknown' }.size()} device(s)" runIn(5, scanAllDevices) } def debugEnabled() { return settings?.debugMode == true } def disableDebugLogging() { log.info "Device Health Monitor: auto-disabling debug logging after 30 minutes" app.updateSetting("debugMode", [value: false, type: "bool"]) } def applyCustomLabel() { if (settings?.customAppName) { if (app.label != settings?.customAppName) { app.updateLabel(settings.customAppName) } } } // ============================================================ // ===================== SNOOZE ============================== // ============================================================ def snoozeEnabled() { return settings?.enableSnooze == true } def snoozeDevice(deviceId) { if (!snoozeEnabled()) return def hours = (settings?.snoozeDurationHours ?: 24).toInteger() def until = now() + (hours * 3600000) if (!state.snoozed) state.snoozed = [:] state.snoozed[deviceId] = until state.snoozed = state.snoozed } def unsnoozeDevice(deviceId) { state.snoozed?.remove(deviceId) state.snoozed = state.snoozed ?: [:] } def isDeviceSnoozed(deviceId) { if (!snoozeEnabled()) return false def until = state.snoozed?.get(deviceId) if (!until) return false if (until >= now()) return true def s = state.snoozed ?: [:] s.remove(deviceId) state.snoozed = s return false } def getSnoozedHoursRemaining(deviceId) { def until = state.snoozed?.get(deviceId) if (!until) return 0 return Math.ceil((until - now()) / 3600000).toInteger() } def formatSnoozeRemaining(deviceId) { def until = state.snoozed?.get(deviceId) if (!until) return "expired" def msLeft = until - now() def days = (msLeft / 86400000).toInteger() def hours = ((msLeft % 86400000) / 3600000).toInteger() def minutes = ((msLeft % 3600000) / 60000).toInteger() if (days >= 1) return "${days}d ${hours}h remaining" if (hours >= 1) return "${hours}h ${minutes}m remaining" return "${minutes}m remaining" } // ============================================================ // ===================== PROTOCOL DETECTION ================== // ============================================================ def getAllMonitoredDevices() { return monitoredDevices ?: [] } def getRawProtocol(device) { try { def driverName = (device.typeName ?: "").toLowerCase() if (driverName.contains("hub variable") || driverName.contains("variable connector")) return "Hub Variable" if (driverName.contains("virtual")) return "Virtual" def devData = device.properties if (devData?.controllerType == "LNK") { def encoding = device.getDataValue("Encoding") if (encoding?.toLowerCase() == "zigbee") return "Hub Mesh (Zigbee)" if (encoding?.toLowerCase() == "z-wave") return "Hub Mesh (Z-Wave)" if (device.getDataValue("In Clusters") != null) return "Hub Mesh (Zigbee)" if (device.getDataValue("inClusters") != null) return "Hub Mesh (Zigbee)" if (device.getDataValue("Out Clusters") != null) return "Hub Mesh (Zigbee)" if (device.getDataValue("outClusters") != null) return "Hub Mesh (Zigbee)" if (device.getDataValue("zigbeeId") != null) return "Hub Mesh (Zigbee)" if (device.getDataValue("zigbeeNodeType") != null) return "Hub Mesh (Zigbee)" if (device.getDataValue("zwaveSecurePairingComplete") != null) return "Hub Mesh (Z-Wave)" if (device.getDataValue("secureInClusters") != null) return "Hub Mesh (Z-Wave)" if (device.getDataValue("Zw Node Info") != null) return "Hub Mesh (Z-Wave)" if (driverName.contains("zigbee") || driverName.contains("thirdreality") || driverName.contains("third reality")) return "Hub Mesh (Zigbee)" if (driverName.contains("z-wave") || driverName.contains("zwave")) return "Hub Mesh (Z-Wave)" if (driverName.contains("matter")) return "Hub Mesh (Matter)" def manufacturer = (device.getDataValue("Manufacturer") ?: "").toLowerCase() if (manufacturer in ["centralite", "lumi", "ikea", "sengled", "osram", "philips", "samsung", "smartthings", "sonoff", "tuya", "third reality", "thirdreality", "third_reality"]) { return "Hub Mesh (Zigbee)" } return "Hub Mesh" } if (devData?.controllerType == "ZGB") return "Zigbee" if (devData?.controllerType == "ZWV") return "Z-Wave" if (devData?.controllerType == "MAT") return "Matter" if (device.getDataValue("Endpoint Id") != null) return "Zigbee" if (device.getDataValue("endpointId") != null) return "Zigbee" if (device.getDataValue("zigbeeNodeType") != null) return "Zigbee" if (device.getDataValue("zigbeeId") != null) return "Zigbee" if (device.getDataValue("In Clusters") != null) return "Z-Wave" if (device.getDataValue("inClusters") != null) return "Z-Wave" if (device.getDataValue("zwaveSecurePairingComplete") != null) return "Z-Wave" if (device.getDataValue("secureInClusters") != null) return "Z-Wave" if (device.getDataValue("Zw Node Info") != null) return "Z-Wave" return "LAN" } catch (e) { if (debugEnabled()) log.debug "getRawProtocol error for ${device.displayName}: ${e.message}" } return "Unknown" } def getProtocol(device) { try { def override = settings["protocolOverride_${device.id}"] if (override && override != "" && override != "Auto-detect") return override return getRawProtocol(device) } catch (e) { if (debugEnabled()) log.debug "getProtocol error for ${device.displayName}: ${e.message}" } return "Unknown" } def getProtocolColor(protocol) { switch (protocol) { case "Zigbee": return "#3b82f6" case "Hub Mesh (Zigbee)": return "#3b82f6" case "Z-Wave": return "#8b5cf6" case "Hub Mesh (Z-Wave)": return "#8b5cf6" case "Matter": return "#e65100" case "Hub Mesh (Matter)": return "#e65100" case "Hub Mesh": return "#06b6d4" case "LAN": return "#14b8a6" case "Virtual": return "#ec4899" case "Hub Variable": return "#eab308" case "Bluetooth": return "#06b6d4" default: return "#c0c4cc" } } def isUnresolvableProtocol(protocol) { return protocol in ["Hub Mesh", "LAN", "Virtual", "Hub Variable"] } def usesFilteredSampling(protocol) { return protocol in ["Virtual", "Hub Variable"] } def isHueDevice(device) { def dn = (device.typeName ?: "").toLowerCase() def dni = (device.deviceNetworkId ?: "").toLowerCase() if (dni.startsWith("hue/")) return true if (dn.startsWith("cocohue")) return true if (dn.contains("huebridgebulb") || dn.contains("huebridge")) return true return false } def findHueBridge() { return getAllMonitoredDevices().find { device -> def dn = (device.typeName ?: "").toLowerCase() def dni = (device.deviceNetworkId ?: "").toLowerCase() (dni.startsWith("hue/") && dn.contains("bridge")) || dn.contains("cocohue bridge") || (dn.contains("huebridge") && !dn.contains("bulb")) } } def findKonnectedPanel(device) { def monitoredPanels = getAllMonitoredDevices().findAll { d -> (d.typeName ?: "").toLowerCase().contains("konnected alarm panel") } if (!monitoredPanels) return null try { def parentId = device.parentDeviceId if (parentId) { def panel = monitoredPanels.find { d -> d.id == parentId } if (panel) return panel } } catch (e) {} def dni = (device.deviceNetworkId ?: "") if (dni.contains("-")) { def parts = dni.split("-") def parentId = parts[0] def suffix = parts.size() > 1 ? parts[1] : "" if (suffix.isNumber() && suffix.toLong() > 1000) { def panel = monitoredPanels.find { d -> d.id == parentId } if (panel) return panel } } return null } def isKonnectedDevice(device) { return findKonnectedPanel(device) != null } def isModeOK() { if (!settings?.enableModeRestriction) return true if (!settings?.restrictedModes) return true return settings.restrictedModes.contains(location.mode) } // ============================================================ // ===================== DEVICE CATEGORY HELPERS ============= // ============================================================ def setDeviceLocation(String deviceId, String loc) { if (!state.deviceLocations) state.deviceLocations = [:] if (loc == null || loc == "") { state.deviceLocations.remove(deviceId) app.removeSetting("loc_${deviceId}") } else { state.deviceLocations[deviceId] = loc app.updateSetting("loc_${deviceId}", [type: "string", value: loc]) } state.deviceLocations = state.deviceLocations } def getDeviceLocation(deviceId) { return state.deviceLocations?.get(deviceId as String) ?: settings["loc_${deviceId}"] ?: "" } def getDeviceDescription(device) { return settings["desc_${device.id}"] ?: "" } // ============================================================ // ===================== PING STATUS HELPER ================== // ============================================================ def markChildrenPingAttempted(String parentId) { def allDevs = getAllMonitoredDevices() def capMap = state.deviceCapabilities ?: [:] allDevs.each { device -> def dni = (device.deviceNetworkId ?: "") def isChild = false try { if (device.parentDeviceId == parentId) isChild = true } catch (e) {} if (!isChild && dni.contains("-")) { def prefix = dni.split("-")[0] if (prefix == parentId) isChild = true } if (isChild) { def capKey = device.id as String def capData = capMap[capKey] ?: [:] if (capData.pingWorks != true) { capData.pingAttempted = true capData.lastPingAttempt = now() capMap[capKey] = capData } } } state.deviceCapabilities = capMap if (debugEnabled()) log.debug "DHM: marked children of ${parentId} as pingAttempted" } def getPingStatus(deviceId) { def capMap = state.deviceCapabilities ?: [:] def cap = capMap[deviceId as String] if (!cap) return "unknown" if (cap.pingWorks == true) return "verified" if (cap.pingWorks == false) return "unverifiable" if (cap.declared == true) return "declared" return "unknown" } def getPingStatusDisplay(deviceId) { switch (getPingStatus(deviceId)) { case "verified": return "✅ Verified" case "unverifiable": return "⚠ Cannot verify" case "declared": return "🔄 Verifiable" default: return "" } } // ============================================================ // ===================== REPEAT DROPS / EXTENDED STATE ======= // ============================================================ def isRepeatDrops(deviceId) { def drops = state.dropHistory?.get(deviceId as String) ?: [] drops = drops.findAll { now() - it < 86400000 } return drops.size() >= 3 } def getExtendedStateTag(device) { if (device.hasAttribute("motion") && device.currentValue("motion") == "active") { try { def stateDate = device.currentState("motion")?.date if (stateDate) { def hoursActive = (now() - stateDate.time) / 3600000 if (hoursActive >= 2) { return " ⏰ Active ${hoursActive.toInteger()}h" } } } catch (e) {} } if (device.hasAttribute("contact") && device.currentValue("contact") == "open") { try { def stateDate = device.currentState("contact")?.date if (stateDate) { def hoursOpen = (now() - stateDate.time) / 3600000 if (hoursOpen >= 24) { return " ⏰ Open ${hoursOpen.toInteger()}h" } } } catch (e) {} } return "" } // ============================================================ // ===================== LOW ACTIVITY ======================== // ============================================================ def isLowActivity(deviceId) { def data = state.history?.get(deviceId) if (!data) return false def samples = data?.samples?.size() ?: 0 def lastSeen = data?.lastSeen ?: now() def ageMs = now() - lastSeen def ageDays = ageMs / (1000.0 * 60 * 60 * 24) return (ageDays >= 7 && samples < 3) } // ============================================================ // ===================== STATE-CHANGE VERIFICATION =========== // ============================================================ def getStateVerified(deviceId) { try { def tracked = state.stateHistory?.get(deviceId as String) if (!tracked?.lastChanged) return false def data = state.history?.get(deviceId as String) if (!data?.lastSeen) return false def stateChangedAfterLastSeen = (tracked.lastChanged as Long) > (data.lastSeen as Long) def thresholdMs = ((settings?.offlineThresholdHours ?: 168) * 60 * 60 * 1000 * 1.0).toLong() // v1.5.3: extended to full window def stateChangeIsRecent = (now() - (tracked.lastChanged as Long)) < thresholdMs return stateChangedAfterLastSeen && stateChangeIsRecent } catch (e) { if (debugEnabled()) log.debug "getStateVerified error for device ${deviceId}: ${e.message}" return false } } // ============================================================ // ===================== STATE TRACKING ====================== // ============================================================ def getMeaningfulAttributes(device) { def known = [ "switch", "contact", "motion", "lock", "presence", "water", "smoke", "carbonMonoxide", "tamper", "shock", "valve", "door", "windowShade", "sleeping", "printState", "thermostatOperatingState", "thermostatMode", "mediaPlaybackStatus", "transportStatus", "chargingState", "currentStatus", "printerStatus", "status", "deviceStatus", "healthStatus", "connectionStatus", "operatingState", "mode" ] def driverName = (device.typeName ?: "").toLowerCase() def deviceName = (device.name ?: "").toLowerCase() def displayName = (device.displayName ?: "").toLowerCase() def nameCheck = "${driverName} ${deviceName} ${displayName}" def isPrinter = nameCheck.contains("moonraker") || nameCheck.contains("klipper") || nameCheck.contains("octoprint") || nameCheck.contains("bambu") || nameCheck.contains("prusa") || nameCheck.contains("3d print") || nameCheck.contains("printer") if (isPrinter) known += ["progress", "currentLayer", "printTime", "remainingTime", "printTimeLeft", "totalLayers", "fileName"] def isThermostat = nameCheck.contains("thermostat") || nameCheck.contains("ecobee") || nameCheck.contains("nest") || nameCheck.contains("honeywell") || nameCheck.contains("sinope") if (isThermostat) known += ["heatingSetpoint", "coolingSetpoint", "thermostatSetpoint", "temperature", "humidity"] def isEV = nameCheck.contains("tesla") || nameCheck.contains("electric vehicle") if (isEV) known += ["battery", "batteryLevel", "chargingState", "range", "odometer"] def isMedia = nameCheck.contains("sonos") || nameCheck.contains("denon") || nameCheck.contains("yamaha") || nameCheck.contains("roku") || nameCheck.contains("apple tv") || nameCheck.contains("media player") if (isMedia) known += ["trackDescription", "trackData", "mediaPlaybackStatus", "transportStatus", "volume", "level"] def found = [] as Set try { device.capabilities?.each { cap -> cap?.attributes?.each { attr -> if (attr?.name && attr.name in known) found << attr.name } } } catch (e) { if (debugEnabled()) log.debug "getMeaningfulAttributes capability scan error for ${device.displayName}: ${e.message}" } try { device.currentStates?.each { s -> if (!s?.name || s?.value == null) return def val = s.value.toString().trim() if (val in ["", "null", "0"]) return def skipAttrs = ["battery", "batteryLastReplaced", "lastCheckin", "temperature", "humidity", "illuminance", "pressure", "carbonDioxide", "energy", "power", "voltage", "current", "frequency", "rssi", "lqi", "driver", "notPresentCounter", "restoredCounter", "firmware"] if (s.name in skipAttrs && !(s.name in known)) return if (val.isNumber() && !(s.name in known)) return found << s.name } } catch (e) { if (debugEnabled()) log.debug "getMeaningfulAttributes currentStates error for ${device.displayName}: ${e.message}" } return found.toList().sort() } def shouldShowStateOverride(device) { def attrs = getMeaningfulAttributes(device) if (attrs.size() > 1) return true if (attrs.size() == 0) return false def driverName = (device.typeName ?: "").toLowerCase() def deviceName = (device.name ?: "").toLowerCase() def displayName = (device.displayName ?: "").toLowerCase() def nameCheck = "${driverName} ${deviceName} ${displayName}" def knownSingleAttrTypes = [ "life360", "presence", "arrival", "mobile app", "lock", "deadbolt", "water", "leak", "smoke", "carbon monoxide", "contact", "door sensor", "window sensor", "motion", "motion sensor", "pir", "valve", "shock", "vibration", "tamper" ] if (knownSingleAttrTypes.any { nameCheck.contains(it) }) return true def overrideCandidateAttrs = [ "presence", "lock", "water", "smoke", "carbonMonoxide", "contact", "motion", "tamper", "shock", "valve", "door" ] if (attrs[0] in overrideCandidateAttrs) return true return false } def hasMultipleMeaningfulAttributes(device) { return shouldShowStateOverride(device) } def getOverrideStateDisplay(device, attrName) { try { def val = device.currentValue(attrName) if (val == null) return null def vs = val.toString().trim() if (vs in ["", "null"]) return null def vl = vs.toLowerCase() switch (attrName) { case "switch": def isOn = vl == "on" return [label: isOn ? "ON" : "OFF", color: isOn ? "#1565c0" : "#c0c4cc", isAlert: false, type: attrName] case "contact": def isOpen = vl == "open" return [label: isOpen ? "Open" : "Closed", color: isOpen ? "#e65100" : "#c0c4cc", isAlert: isOpen, type: attrName] case "motion": def isActive = vl == "active" return [label: isActive ? "Active" : "Inactive", color: isActive ? "#1565c0" : "#c0c4cc", isAlert: false, type: attrName] case "lock": def isUnlocked = vl == "unlocked" return [label: isUnlocked ? "Unlocked" : "Locked", color: isUnlocked ? "#e65100" : "#c0c4cc", isAlert: isUnlocked, type: attrName] case "presence": def isPresent = vl == "present" return [label: isPresent ? "Present" : "Not Present", color: isPresent ? "#1565c0" : "#c0c4cc", isAlert: false, type: attrName] case "water": def isWet = vl == "wet" return [label: isWet ? "Wet" : "Dry", color: isWet ? "#c62828" : "#c0c4cc", isAlert: isWet, type: attrName] case "smoke": case "carbonMonoxide": case "tamper": def isDetected = vl == "detected" return [label: isDetected ? "${attrName.capitalize()}!" : "Clear", color: isDetected ? "#c62828" : "#c0c4cc", isAlert: isDetected, type: attrName] case "valve": case "door": def isOpen2 = vl in ["open", "opening"] return [label: vs.capitalize(), color: isOpen2 ? "#e65100" : "#c0c4cc", isAlert: isOpen2, type: attrName] case "printState": case "currentStatus": case "printerStatus": def isPrint = vl in ["printing", "busy"] def isPause = vl in ["paused", "pausing"] def isError = vl in ["error", "offline", "disconnected", "cancelled"] def color = isPrint ? "#1565c0" : isPause ? "#e65100" : isError ? "#c62828" : "#c0c4cc" return [label: vs.capitalize(), color: color, isAlert: isError, type: attrName] default: def isActive = vl in ["on", "active", "connected", "online", "running", "enabled", "playing", "present", "open"] def isAlert = vl in ["offline", "disconnected", "error", "fault", "alarm", "wet", "detected"] def color = isAlert ? "#c62828" : isActive ? "#1565c0" : "#c0c4cc" return [label: vs.capitalize(), color: color, isAlert: isAlert, type: attrName] } } catch (e) { if (debugEnabled()) log.debug "getOverrideStateDisplay error for ${device.displayName}: ${e.message}" } return null } def getCurrentStateDisplay(device) { try { def attrOverride = settings["stateAttrOverride_${device.id}"] if (attrOverride && attrOverride != "Auto-detect") { def overrideResult = getOverrideStateDisplay(device, attrOverride) if (overrideResult) return overrideResult } def driverName = (device.typeName ?: "").toLowerCase() def isContactDevice = driverName.contains("contact") || driverName.contains("door sensor") || driverName.contains("window sensor") if (isContactDevice) { def contact = device.currentValue("contact") if (contact != null) { def isOpen = contact.toString().toLowerCase() == "open" return [label: isOpen ? "Open" : "Closed", color: isOpen ? "#e65100" : "#c0c4cc", isAlert: isOpen, type: "contact"] } } def isMotionDevice = driverName.contains("motion sensor") || driverName.contains("motion detector") || driverName.contains("pir") if (isMotionDevice) { def motion = device.currentValue("motion") if (motion != null) { def isActive = motion.toString().toLowerCase() == "active" return [label: isActive ? "Active" : "Inactive", color: isActive ? "#1565c0" : "#c0c4cc", isAlert: false, type: "motion"] } } def isLockDevice = driverName.contains("lock") && !driverName.contains("unlock") if (isLockDevice) { def lock = device.currentValue("lock") if (lock != null) { def isUnlocked = lock.toString().toLowerCase() == "unlocked" return [label: isUnlocked ? "Unlocked" : "Locked", color: isUnlocked ? "#e65100" : "#c0c4cc", isAlert: isUnlocked, type: "lock"] } } def isPresenceDevice = driverName.contains("life360") || driverName.contains("presence") || driverName.contains("arrival") || driverName.contains("mobile") if (isPresenceDevice) { def presence = device.currentValue("presence") if (presence != null) { def isPresent = presence.toString().toLowerCase() == "present" return [label: isPresent ? "Present" : "Not Present", color: isPresent ? "#1565c0" : "#c0c4cc", isAlert: false, type: "presence"] } } def isPrinter = driverName.contains("moonraker") || driverName.contains("klipper") || driverName.contains("octoprint") || driverName.contains("bambu") || driverName.contains("prusa") || driverName.contains("3d print") || driverName.contains("printer") if (isPrinter) { def printerStatus = device.currentValue("printState") ?: device.currentValue("currentStatus") ?: device.currentValue("printerStatus") ?: device.currentValue("status") if (printerStatus != null) { def ps = printerStatus.toString().toLowerCase() def isPrint = ps in ["printing", "busy"] def isIdle = ps in ["idle", "ready", "operational", "standby", "complete"] def isPause = ps in ["paused", "pausing"] def isError = ps in ["error", "offline", "disconnected", "cancelled"] def color = isPrint ? "#1565c0" : isIdle ? "#c0c4cc" : isPause ? "#e65100" : isError ? "#c62828" : "#c0c4cc" return [label: printerStatus.toString().capitalize(), color: color, isAlert: isError, type: "printerStatus"] } } def water = device.currentValue("water") if (water != null) { def isWet = water.toString().toLowerCase() == "wet" return [label: isWet ? "Wet" : "Dry", color: isWet ? "#c62828" : "#c0c4cc", isAlert: isWet, type: "water"] } def smoke = device.currentValue("smoke") if (smoke != null) { def isDetected = smoke.toString().toLowerCase() == "detected" return [label: isDetected ? "Smoke!" : "Clear", color: isDetected ? "#c62828" : "#c0c4cc", isAlert: isDetected, type: "smoke"] } def co = device.currentValue("carbonMonoxide") if (co != null) { def isDetected = co.toString().toLowerCase() == "detected" return [label: isDetected ? "CO!" : "Clear", color: isDetected ? "#c62828" : "#c0c4cc", isAlert: isDetected, type: "carbonMonoxide"] } def sw = device.currentValue("switch") if (sw != null) { def isOn = sw.toString().toLowerCase() == "on" return [label: isOn ? "ON" : "OFF", color: isOn ? "#1565c0" : "#c0c4cc", isAlert: false, type: "switch"] } def presence = device.currentValue("presence") if (presence != null) { def isPresent = presence.toString().toLowerCase() == "present" return [label: isPresent ? "Present" : "Not Present", color: isPresent ? "#1565c0" : "#c0c4cc", isAlert: false, type: "presence"] } def contact = device.currentValue("contact") if (contact != null) { def isOpen = contact.toString().toLowerCase() == "open" return [label: isOpen ? "Open" : "Closed", color: isOpen ? "#e65100" : "#c0c4cc", isAlert: isOpen, type: "contact"] } def motion = device.currentValue("motion") if (motion != null) { def isActive = motion.toString().toLowerCase() == "active" return [label: isActive ? "Active" : "Inactive", color: isActive ? "#1565c0" : "#c0c4cc", isAlert: false, type: "motion"] } def lock = device.currentValue("lock") if (lock != null) { def isUnlocked = lock.toString().toLowerCase() == "unlocked" return [label: isUnlocked ? "Unlocked" : "Locked", color: isUnlocked ? "#e65100" : "#c0c4cc", isAlert: isUnlocked, type: "lock"] } def tamper = device.currentValue("tamper") if (tamper != null) { def isDetected = tamper.toString().toLowerCase() == "detected" return [label: isDetected ? "Tampered!" : "Clear", color: isDetected ? "#c62828" : "#c0c4cc", isAlert: isDetected, type: "tamper"] } def shock = device.currentValue("shock") if (shock != null) { def isDetected = shock.toString().toLowerCase() == "detected" return [label: isDetected ? "Detected" : "Clear", color: isDetected ? "#e65100" : "#c0c4cc", isAlert: isDetected, type: "shock"] } def sleeping = device.currentValue("sleeping") if (sleeping != null) { def isSleeping = sleeping.toString().toLowerCase() == "sleeping" return [label: isSleeping ? "Sleeping" : "Not Sleeping", color: isSleeping ? "#8b5cf6" : "#c0c4cc", isAlert: false, type: "sleeping"] } def valve = device.currentValue("valve") if (valve != null) { def isOpen = valve.toString().toLowerCase() == "open" return [label: isOpen ? "Open" : "Closed", color: isOpen ? "#e65100" : "#c0c4cc", isAlert: isOpen, type: "valve"] } def door = device.currentValue("door") if (door != null) { def isOpen = door.toString().toLowerCase() in ["open", "opening"] return [label: door.toString().capitalize(), color: isOpen ? "#e65100" : "#c0c4cc", isAlert: isOpen, type: "door"] } def shade = device.currentValue("windowShade") if (shade != null) { def isOpen = shade.toString().toLowerCase() in ["open", "opening", "partially open"] return [label: shade.toString().capitalize(), color: isOpen ? "#1565c0" : "#c0c4cc", isAlert: false, type: "windowShade"] } def protocol = getProtocol(device) def isLANType = protocol in ["LAN", "Hub Mesh", "Hub Mesh (Zigbee)", "Hub Mesh (Z-Wave)", "Hub Mesh (Matter)", "Unknown"] if (isLANType) { def lanResult = getLANStateDisplay(device) if (lanResult != null) return lanResult } def temp = device.currentValue("temperature") if (temp != null) { def unit = location?.temperatureScale ?: "F" return [label: "${temp}°${unit}", color: "#c0c4cc", isAlert: false, type: "temperature"] } def humidity = device.currentValue("humidity") if (humidity != null) { return [label: "${humidity}% RH", color: "#c0c4cc", isAlert: false, type: "humidity"] } def co2 = device.currentValue("carbonDioxide") if (co2 != null) { return [label: "${co2} ppm CO₂", color: "#c0c4cc", isAlert: false, type: "carbonDioxide"] } } catch (e) { if (debugEnabled()) log.debug "getCurrentStateDisplay error for ${device.displayName}: ${e.message}" } return null } def getLANStateDisplay(device) { try { def currentStates = device.currentStates if (!currentStates) return null def stateMap = [:] currentStates.each { s -> if (s?.name && s?.value != null) stateMap[s.name] = s.value.toString() } if (!stateMap) return null def driverName = (device.typeName ?: "").toLowerCase() def isThermostat = driverName.contains("thermostat") || driverName.contains("ecobee") || driverName.contains("nest") || driverName.contains("honeywell") || driverName.contains("sinope") || stateMap.containsKey("thermostatMode") || stateMap.containsKey("thermostatOperatingState") if (isThermostat) { def opState = stateMap["thermostatOperatingState"] def mode = stateMap["thermostatMode"] def temp = stateMap["temperature"] def setpt = stateMap["thermostatSetpoint"] ?: stateMap["coolingSetpoint"] ?: stateMap["heatingSetpoint"] if (opState) { def isActive = opState.toLowerCase() in ["heating", "cooling", "fan only", "pending heat", "pending cool"] def color = opState.toLowerCase() == "heating" ? "#e65100" : opState.toLowerCase() == "cooling" ? "#1565c0" : isActive ? "#1565c0" : "#c0c4cc" def label = opState.capitalize() if (temp) label += " ${temp}°" if (setpt) label += " → ${setpt}°" return [label: label, color: color, isAlert: false, type: "thermostat"] } if (mode) { def isOff = mode.toLowerCase() == "off" return [label: "Mode: ${mode.capitalize()}", color: isOff ? "#c0c4cc" : "#1565c0", isAlert: false, type: "thermostat"] } } def isMedia = driverName.contains("sonos") || driverName.contains("denon") || driverName.contains("yamaha") || driverName.contains("roku") || driverName.contains("apple tv") || driverName.contains("media player") || stateMap.containsKey("trackDescription") || stateMap.containsKey("mediaPlaybackStatus") || stateMap.containsKey("transportStatus") if (isMedia) { def playback = stateMap["mediaPlaybackStatus"] ?: stateMap["transportStatus"] ?: stateMap["status"] if (playback) { def isPlaying = playback.toLowerCase() in ["playing", "play"] def color = isPlaying ? "#1565c0" : "#c0c4cc" def track = stateMap["trackDescription"] ?: stateMap["trackData"] ?: "" def label = playback.capitalize() if (track && track.length() > 0 && track != "null") { label += ": " + (track.length() > 20 ? track[0..19] + "…" : track) } return [label: label, color: color, isAlert: false, type: "media"] } } def isEV = driverName.contains("tesla") || driverName.contains("electric vehicle") || stateMap.containsKey("chargingState") || stateMap.containsKey("batteryLevel") if (isEV) { def charging = stateMap["chargingState"] def battery = stateMap["battery"] ?: stateMap["batteryLevel"] if (charging) { def isCharging = charging.toLowerCase() in ["charging", "complete"] def color = charging.toLowerCase() == "complete" ? "#16a34a" : isCharging ? "#1565c0" : "#c0c4cc" def label = charging.capitalize() if (battery) label += " ${battery}%" return [label: label, color: color, isAlert: false, type: "ev"] } if (battery) { def pct = battery.isNumber() ? battery.toInteger() : 0 def color = pct < 20 ? "#c62828" : pct < 40 ? "#e65100" : "#c0c4cc" return [label: "Battery ${battery}%", color: color, isAlert: pct < 20, type: "ev"] } } def isShelly = driverName.contains("shelly") || stateMap.containsKey("power") || stateMap.containsKey("energy") if (isShelly && stateMap.containsKey("switch")) { def sw = stateMap["switch"] def isOn = sw?.toLowerCase() == "on" def power = stateMap["power"] def label = isOn ? "ON" : "OFF" if (isOn && power && power.isNumber()) label += " ${power.toDouble().round(1)}W" return [label: label, color: isOn ? "#1565c0" : "#c0c4cc", isAlert: false, type: "shelly"] } if (stateMap.containsKey("door")) { def d = stateMap["door"].toLowerCase() def isOpen = d in ["open", "opening"] return [label: stateMap["door"].capitalize(), color: isOpen ? "#e65100" : "#c0c4cc", isAlert: isOpen, type: "door"] } def rankedAttrs = [ "status", "deviceStatus", "healthStatus", "connectionStatus", "systemStatus", "operatingState", "currentState", "active", "enabled", "connected", "running", "mode", "level", "speed", "inputSource" ] for (attr in rankedAttrs) { def val = stateMap[attr] if (val == null) continue def vl = val.toLowerCase().trim() if (vl in ["unknown", "null", "", "none", "0", "false", "true"]) continue if (val.isNumber()) continue if (val.length() > 30) continue if (val.contains(": ")) continue def isActive = vl in ["on", "active", "connected", "online", "running", "enabled", "open", "playing", "present", "idle", "ready", "operational"] def isAlert = vl in ["offline", "disconnected", "error", "fault", "alarm", "wet", "detected"] def color = isAlert ? "#c62828" : isActive ? "#1565c0" : "#c0c4cc" return [label: val.capitalize(), color: color, isAlert: isAlert, type: attr] } } catch (e) { if (debugEnabled()) log.debug "getLANStateDisplay error for ${device.displayName}: ${e.message}" } return null } def updateStateTracking(device) { try { def id = device.id def stateInfo = getCurrentStateDisplay(device) if (!stateInfo) return def currentVal = stateInfo.label def sh = state.stateHistory ?: [:] def tracked = sh[id] if (!tracked) { sh[id] = [lastValue: currentVal, lastChanged: now()] state.stateHistory = sh return } if (tracked.lastValue != currentVal) { sh[id] = [lastValue: currentVal, lastChanged: now()] state.stateHistory = sh if (debugEnabled()) log.debug "${device.displayName}: state changed to ${currentVal}" } } catch (e) { if (debugEnabled()) log.debug "updateStateTracking error for ${device.displayName}: ${e.message}" } } // ============================================================ // ===================== HUB MESH GROUPING =================== // ============================================================ def getHubMeshSourceHub(device) { try { def hubName = device.getDataValue("hubName") ?: device.getDataValue("HubName") if (hubName) return hubName def dni = device.deviceNetworkId ?: "" if (dni.contains(":")) { def prefix = dni.split(":")[0] if (prefix && prefix.length() > 2 && !prefix.matches("[0-9A-Fa-f]+")) return prefix } } catch (e) { } return "Remote Hub" } def buildHubMeshSummary() { def devList = getAllMonitoredDevices().findAll { p -> getProtocol(p).startsWith("Hub Mesh") } def groups = [:] devList.each { device -> def srcHub = getHubMeshSourceHub(device) if (!groups[srcHub]) groups[srcHub] = [total: 0, offline: 0, poor: 0, fair: 0, good: 0, excellent: 0, pending: 0] groups[srcHub].total++ def h = state.health?.get(device.id) ?: "Pending" switch (h) { case "Offline": groups[srcHub].offline++; break case "Poor": groups[srcHub].poor++; break case "Fair": groups[srcHub].fair++; break case "Good": groups[srcHub].good++; break case "Excellent": groups[srcHub].excellent++; break default: groups[srcHub].pending++; break } } return groups } // ============================================================ // ===================== MAIN PAGE =========================== // ============================================================ def mainPage() { applyCustomLabel() dynamicPage(name: "mainPage", title: "", install: true, uninstall: true) { def currentLabel = app.label ?: "Device Health Monitor" section("App Display Name${currentLabel}", hideable: true, hidden: true) { paragraph "Enter a name to rename this app in your Hubitat app list." input "customAppName", "text", title: "Custom App Name", required: false } def portalEnabled = state.accessToken != null def portalStatus = portalEnabled ? "Enabled" : "Not Enabled" def portalSectionTitle = "Device Health Portal — ${portalStatus}" section(portalSectionTitle, hideable: true, hidden: portalEnabled) { if (portalEnabled) { def cloudUrl = getFullApiServerUrl() def localUrl = getFullLocalApiServerUrl() paragraph "
" + "Cloud URL (use anywhere):
" + "${cloudUrl}/dashboard?access_token=${state.accessToken}

" + "Local URL (use at home):
" + "${localUrl}/dashboard?access_token=${state.accessToken}" + "
" } else { def hubIp = location?.hub?.localIP ?: "" paragraph "
" + "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 AppUpdate
" + "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 "" + "" + "" + "

🔄 Refreshing...

${msgText}

" } def forceRefreshEndpoint() { try { runIn(1, "scanAllDevices", [overwrite: true]) return render(contentType: "text/html", data: getPortalRedirectHtml(3000, "Running health scan..."), status: 200) } catch (e) { log.error "Device Health Monitor portal refresh error: ${e}" return render(contentType: "text/html", data: "Error: ${e.message}", status: 500) } } def updateDeviceEndpoint() { try { def dId = params?.deviceId if (dId) { if (params.loc != null) { setDeviceLocation(dId, params.loc) } if (params.desc != null) { if (params.desc == "") app.removeSetting("desc_${dId}") else app.updateSetting("desc_${dId}", [type: "text", value: params.desc]) } if (debugEnabled()) log.debug "Portal updated device ${dId}: loc=${params.loc} desc=${params.desc}" } return render(contentType: "application/json", data: '{"success":true}', status: 200) } catch (e) { log.error "Device Health Monitor updateDevice error: ${e}" return render(contentType: "application/json", data: '{"error":"'+e+'"}', status: 500) } } // ============================================================ // ===================== PORTAL ENDPOINT: DATA =============== // ============================================================ def serveDataEndpoint() { try { def devList = getAllMonitoredDevices().findAll { getProtocol(it) != "Unknown" } def roomOptions = getRoomOptions() def estate = devList.collect { device -> def data = state.history?.get(device.id) def h = state.health?.get(device.id) ?: "Pending" def protocol = getProtocol(device) def snoozed = isDeviceSnoozed(device.id as String) def lastSeenMs = data?.lastSeen ? (data.lastSeen as Long) : 0 def lastSeenStr = lastSeenMs ? formatTimeAgo(lastSeenMs) : "Never" def avgIntStr = data?.userInterval ? formatInterval(data.userInterval) + " (manual)" : data?.avgInterval ? formatInterval(data.avgInterval) : "Learning..." def stateInfo = getCurrentStateDisplay(device) def stateLabel = stateInfo?.label ?: "—" def stateColor = stateInfo?.color ?: "#c0c4cc" def isAlert = stateInfo?.isAlert ?: false def tracked = state.stateHistory?.get(device.id as String) def lastChanged = tracked?.lastChanged ? formatTimeAgo(tracked.lastChanged as Long) : "—" def loc = getDeviceLocation(device.id) def desc = settings["desc_${device.id}"] ?: "" def verifyMethod = state.verifying?.get(device.id) def hasOverride = settings["protocolOverride_${device.id}"] && settings["protocolOverride_${device.id}"] != "Auto-detect" [ id: device.id, name: device.displayName, health: h, protocol: protocol, protocolColor: getProtocolColor(protocol), hasOverride: hasOverride, snoozed: snoozed, snoozeRemaining: snoozed ? formatSnoozeRemaining(device.id as String) : "", lastSeen: lastSeenStr, lastSeenMs: lastSeenMs, avgInterval: avgIntStr, stateLabel: stateLabel, stateColor: stateColor, stateAlert: isAlert, lastChanged: lastChanged, location: loc, description: desc, pingStatus: getPingStatus(device.id), repeatDrops: isRepeatDrops(device.id as String), extStateTag: getExtendedStateTag(device), verifyMethod: verifyMethod ?: "", lowActivity: isLowActivity(device.id as String) ] } def healthOrder = ["Offline": 1, "Poor": 2, "Fair": 3, "Good": 4, "Excellent": 5, "Pending": 6] estate = estate.sort { a, b -> def pA = healthOrder[a.health] ?: 6 def pB = healthOrder[b.health] ?: 6 if (pA != pB) return pA <=> pB return a.name <=> b.name } def payload = [ token: state.accessToken, lastScan: new Date().format("MM/dd/yyyy h:mm a", location.timeZone), locations: roomOptions, isScanning: state.isScanning ?: false, estate: estate ] return render(contentType: "application/json", data: groovy.json.JsonOutput.toJson(payload), status: 200) } catch (e) { log.error "Device Health Monitor data endpoint error: ${e}" return render(contentType: "application/json", data: '{"error":"Data unavailable"}', status: 500) } } // ============================================================ // ===================== PORTAL ENDPOINT: DASHBOARD (SPA) ==== // ============================================================ def serveDashboardPage() { try { def css = """ body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;padding:20px;background:#0d0d0d;color:#e0e0e0;margin:0} .container{max-width:860px;margin:0 auto;background:#151515;padding:25px;border-radius:12px;box-sizing:border-box} .loader{border:4px solid #333;border-top:4px solid #3b82f6;border-radius:50%;width:40px;height:40px;animation:spin 1s linear infinite;margin:30px auto} @keyframes spin{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}} h2{text-align:center;color:#fff;margin:0 0 4px 0} .subtitle{text-align:center;font-size:12px;color:#666;margin-bottom:20px} .summary-box{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:20px} .summary-card{flex:1;min-width:90px;box-sizing:border-box;background:#1e1e1e;padding:12px;border-radius:8px;text-align:center;border-bottom:3px solid #333} .summary-card b{display:block;font-size:22px;color:#fff;margin-bottom:4px} .summary-card span{font-size:11px;color:#aaa;text-transform:uppercase} .top-bar{display:flex;gap:10px;margin-bottom:20px;flex-wrap:wrap} .btn{flex:1;background:#1f618d;color:#fff;border:none;padding:13px 16px;border-radius:8px;text-align:center;text-decoration:none;font-weight:600;cursor:pointer;font-size:13px;display:block} .btn:hover{background:#1a5276} details{margin-bottom:12px} summary{padding:10px 14px;background:#1c1c1c;border-radius:6px;border-left:4px solid #3b82f6;cursor:pointer;color:#fff;font-weight:bold;font-size:15px;list-style:none} summary:hover{background:#252525} .cat-count{float:right;font-size:12px;color:#888;margin-top:2px} .dev-card{background:#222;padding:12px 14px;border-radius:8px;margin-bottom:10px;border-left:4px solid #333} .health-Offline{border-left-color:#991b1b;background:linear-gradient(90deg,rgba(153,27,27,.12) 0%,#222 30%)} .health-Poor{border-left-color:#ef4444;background:linear-gradient(90deg,rgba(239,68,68,.1) 0%,#222 30%)} .health-Fair{border-left-color:#f97316;background:linear-gradient(90deg,rgba(249,115,22,.1) 0%,#222 30%)} .health-Good{border-left-color:#22c55e} .health-Excellent{border-left-color:#22c55e} .health-Pending{border-left-color:#94a3b8} .health-Snoozed{border-left-color:#8b5cf6;opacity:0.7} .row{display:flex;align-items:flex-start;gap:10px} .dev-name{font-size:14px;font-weight:bold;color:#fff} .dev-meta{font-size:11px;color:#888;margin-top:3px} .dev-state{display:inline-block;padding:2px 8px;border-radius:8px;font-size:11px;font-weight:700;margin-top:4px} .dev-health{font-size:12px;margin-top:4px;color:#ccc} .proto-tag{display:inline-block;font-size:10px;font-weight:700;padding:1px 6px;border-radius:4px;margin-left:6px;vertical-align:middle} .modal-overlay{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.75);z-index:1000;align-items:center;justify-content:center;padding:20px;box-sizing:border-box} .modal{background:#1a1a1a;border:1px solid #333;border-radius:12px;padding:22px;width:100%;max-width:500px;max-height:90vh;overflow-y:auto;position:relative} .modal-title{font-size:17px;font-weight:bold;color:#fff;margin-bottom:15px;border-bottom:1px solid #333;padding-bottom:10px} .modal-row{display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid #1e1e1e;font-size:13px} .modal-row span:first-child{color:#888} .modal-row span:last-child{color:#fff;text-align:right;max-width:60%} .modal-input{width:100%;padding:7px;border-radius:4px;border:1px solid #444;background:#222;color:#fff;font-size:13px;box-sizing:border-box;margin-top:3px} .modal-input:focus{outline:none;border-color:#3b82f6} .modal-btns{display:flex;gap:10px;margin-top:15px} .modal-save{flex:1;padding:11px;background:#22c55e;color:#fff;border:none;border-radius:6px;font-weight:bold;cursor:pointer} .modal-save:hover{background:#16a34a} .modal-cancel{flex:1;padding:11px;background:#2c3e50;color:#fff;border:none;border-radius:6px;font-weight:bold;cursor:pointer} .modal-cancel:hover{background:#34495e} .scanning-badge{background:#1f618d;color:#fff;font-size:11px;padding:3px 8px;border-radius:10px;margin-left:8px} select.top-select{background:#2c3e50;color:#fff;border:none;border-radius:8px;padding:0 12px;font-size:13px;font-weight:600;cursor:pointer;height:44px} @media(max-width:600px){body{padding:10px}.container{padding:14px}.row{flex-wrap:wrap}.summary-card{min-width:40%}} """ def js = """ const ACCESS_TOKEN = '${state.accessToken}'; let db = null; let groupMode = 'protocol'; function load() { document.getElementById('app').innerHTML = "

Loading estate data...

"; fetch('data?access_token=' + ACCESS_TOKEN) .then(r => r.json()) .then(data => { db = data; render(); }) .catch(err => { document.getElementById('app').innerHTML = "

Connection error — hub may be busy.
" + err + "

"; }); } function silentRefresh() { if (document.getElementById('editModal').style.display !== 'flex') { fetch('data?access_token=' + ACCESS_TOKEN) .then(r => r.json()) .then(data => { db = data; render(); }); } } function healthIcon(h) { return {Offline:'💀',Poor:'🔴',Fair:'🟠',Good:'🟢',Excellent:'🟢',Pending:'⏳'}[h] || '⏳'; } function healthLabel(dev) { let h = dev.health; let icon = healthIcon(h); if (dev.snoozed) return "😴 Snoozed (" + dev.snoozeRemaining + ")"; if (h === 'Pending') return "⏳ Pending"; let suffix = ''; if (dev.repeatDrops) suffix = ' 🔄 Repeat Drops'; else if (dev.lowActivity && (h === 'Fair' || h === 'Poor' || h === 'Offline')) suffix = ' ℹ️ Low Activity'; if (h === 'Poor' || h === 'Offline') { let vm = dev.verifyMethod; let vSuffix = ''; if (vm === 'state_verified') vSuffix = ' ✅ State verified'; else if (vm === 'refresh' || vm === 'ping') vSuffix = ' 🔄 Verifying...'; else if (vm === 'none' || vm === 'virtual') vSuffix = ' ⚠ Cannot verify'; else if (vm === 'hue_bridge') vSuffix = ' 🔄 Hue Bridge refresh sent'; else if (vm === 'hue_no_bridge') vSuffix = ' ⚠ Add Hue Bridge'; else if (vm === 'konnected_panel') vSuffix = ' 🔄 Konnected Panel refresh sent'; else if (vm === 'konnected_no_panel') vSuffix = ' ⚠ Add Konnected Panel'; else if (vm === 'konnected_panel_failed') vSuffix = ' ⚠ Konnected Panel refresh failed'; return icon + ' ' + h + suffix + vSuffix; } return icon + ' ' + h + suffix; } function stateTag(dev) { if (dev.stateLabel === '—') return ''; let bg = dev.stateColor === '#c62828' ? '#fee2e2;color:#b91c1c' : dev.stateColor === '#e65100' ? '#fff3e0;color:#c2410c' : dev.stateColor === '#1565c0' ? '#dbeafe;color:#1d4ed8' : dev.stateColor === '#8b5cf6' ? '#f3e8ff;color:#7c3aed' : dev.stateColor === '#16a34a' ? '#dcfce7;color:#15803d' : 'transparent;color:#4b5563'; return "" + dev.stateLabel + ""; } function protoTag(dev) { return "" + dev.protocol + (dev.hasOverride ? ' (override)' : '') + ""; } function card(dev) { let locDesc = []; if (dev.location) locDesc.push('🏷️ ' + dev.location); let pingTag = dev.pingStatus === 'verified' ? "✅ Verified" : dev.pingStatus === 'unverifiable' ? "⚠ Cannot verify" : dev.pingStatus === 'declared' ? "🔄 Verifiable" : ''; if (dev.description) locDesc.push('📝 ' + dev.description); let locHtml = locDesc.length ? "
" + locDesc.join('  |  ') + "
" : ''; return "
" + "
" + "
" + dev.name + protoTag(dev) + "
" + locHtml + "
" + healthLabel(dev) + "
" + stateTag(dev) + (pingTag ? "
" + pingTag + "
" : "") + "
" + "
Last: " + dev.lastSeen + "
" + "
Avg: " + dev.avgInterval + "
" + (dev.lastChanged !== '—' ? "
Changed: " + dev.lastChanged + "
" : '') + "
"; } function render() { if (!db) return; let estate = db.estate || []; let offline = estate.filter(d => d.health === 'Offline' && !d.snoozed).length; let poor = estate.filter(d => d.health === 'Poor' && !d.snoozed).length; let fair = estate.filter(d => d.health === 'Fair' && !d.snoozed).length; let healthy = estate.filter(d => ['Good','Excellent'].includes(d.health) && !d.snoozed).length; let total = estate.length; let scanning = db.isScanning ? "🔄 Scanning" : ""; let html = "

📡 Device Health

"; html += "

Last scan: " + db.lastScan + scanning + "

"; html += "
"; html += "
" + offline + "Offline
"; html += "
" + poor + "Poor
"; html += "
" + fair + "Fair
"; html += "
" + healthy + "Healthy
"; html += "
" + total + "Total
"; html += "
"; html += "
"; html += "🔄 Force Scan"; html += ""; html += "
"; let issues = estate.filter(d => ['Offline','Poor','Fair'].includes(d.health) && !d.snoozed); if (issues.length) { html += "
⚠️ Active Issues " + issues.length + " devices
"; issues.forEach(d => html += card(d)); html += "
"; } let snoozed = estate.filter(d => d.snoozed); if (snoozed.length) { html += "
😴 Snoozed " + snoozed.length + "
"; snoozed.forEach(d => html += card(d)); html += "
"; } let healthy_devs = estate.filter(d => !['Offline','Poor','Fair'].includes(d.health) && !d.snoozed); let groups = {}; healthy_devs.forEach(d => { let keys = []; if (groupMode === 'protocol') keys = [d.protocol]; else if (groupMode === 'health') keys = [d.health]; else if (groupMode === 'location') keys = [d.location || 'Unassigned']; keys.forEach(k => { if (!groups[k]) groups[k] = []; groups[k].push(d); }); }); Object.keys(groups).sort().forEach(gName => { html += "
" + gName + " " + groups[gName].length + " devices
"; groups[gName].forEach(d => html += card(d)); html += "
"; }); document.getElementById('app').innerHTML = html; } function changeGroup(mode) { groupMode = mode; render(); } function openEdit(card) { document.getElementById('editDeviceId').value = card.getAttribute('data-id'); document.getElementById('editDeviceName').innerText = card.getAttribute('data-name'); document.getElementById('modalHealth').innerText = card.getAttribute('data-health') || '—'; document.getElementById('modalProtocol').innerText = card.getAttribute('data-protocol') || '—'; document.getElementById('modalLastSeen').innerText = card.getAttribute('data-lastseen') || '—'; document.getElementById('modalAvgInt').innerText = card.getAttribute('data-avginterval') || '—'; let locSel = document.getElementById('editLoc'); locSel.innerHTML = ''; (db.locations || []).forEach(l => { let opt = new Option(l, l); if (l === card.getAttribute('data-loc')) opt.selected = true; locSel.add(opt); }); let current = card.getAttribute('data-loc'); if (current && !Array.from(locSel.options).some(o => o.value === current)) { let newOpt = new Option(current, current, false, true); locSel.add(newOpt); locSel.value = current; } document.getElementById('editDesc').value = card.getAttribute('data-desc') || ''; document.getElementById('editModal').style.display = 'flex'; } function saveEdit() { let btn = document.querySelector('.modal-save'); btn.innerText = '⏳ Saving...'; btn.disabled = true; let dId = document.getElementById('editDeviceId').value; let loc = document.getElementById('editLoc').value; let desc = document.getElementById('editDesc').value; fetch('updateDevice?deviceId=' + dId + '&loc=' + encodeURIComponent(loc) + '&desc=' + encodeURIComponent(desc) + '&access_token=' + ACCESS_TOKEN) .then(r => r.json()) .then(() => { btn.innerText = '💾 Save'; btn.disabled = false; let dev = db.estate.find(d => d.id == dId); if (dev) { dev.location = loc; dev.description = desc; } closeEdit(); render(); }) .catch(() => { btn.innerText = '💾 Save'; btn.disabled = false; alert('Save failed.'); }); } function closeEdit() { document.getElementById('editModal').style.display = 'none'; } document.addEventListener('keydown', e => { if (e.key === 'Escape') closeEdit(); }); document.addEventListener('DOMContentLoaded', load); setInterval(silentRefresh, 60000); """ def html = "" + "" + "Device Health Portal" + "" + "
" + "" + "" return render(contentType: "text/html", data: html, status: 200) } catch (Exception e) { log.error "Device Health Monitor portal error: ${e}" return render(contentType: "text/html", data: "

Portal Error

${e}

", status: 500) } } // ============================================================ // ===================== ACTIVITY SUMMARY PAGE =============== // ============================================================ def activitySummaryPage() { dynamicPage(name: "activitySummaryPage", title: "Device Activity Summary", install: false) { section("") { paragraph rawHtml: true, """ """ href(name: "toForceScan", page: "forceScanPage", title: "🔄 Force Scan Now") if (snoozeEnabled()) { href(name: "toSnoozeFromSummary", page: "snoozeManagePage", title: "😴 Manage Snoozed Devices") } if (state.isScanning) { paragraph "
🔄 Scan in progress — data updates automatically as each batch completes.
" } else { paragraph "
🔄 Data reflects the last completed scan. Tap Force Scan above to refresh.
" } def devList = getAllMonitoredDevices().findAll { getProtocol(it) != "Unknown" } if (!devList) { paragraph "No devices found. Please select devices on the main page first."; return } devList = devList.sort { a, b -> def healthPriority = ["Offline": 1, "Poor": 2, "Fair": 3, "Good": 4, "Excellent": 5, "Pending": 6] def hA = state.health?.get(a.id) ?: "Pending" def hB = state.health?.get(b.id) ?: "Pending" def pA = healthPriority[hA] ?: 6 def pB = healthPriority[hB] ?: 6 if (pA != pB) return pA <=> pB return a.displayName.trim() <=> b.displayName.trim() } def hubIp = location?.hub?.localIP ?: "" def table = "" table += "" table += "" table += "" table += "" table += "" table += "" table += "" table += "" table += "" table += "" table += "" def rowNum = 0 devList.each { device -> def data = state.history?.get(device.id) def protocol = getProtocol(device) def snoozed = isDeviceSnoozed(device.id as String) def hasOverride = settings["protocolOverride_${device.id}"] && settings["protocolOverride_${device.id}"] != "Auto-detect" def rowBg = snoozed ? "#f8f8f8" : (rowNum % 2 == 0) ? "#ffffff" : "#ebebeb" def protocolDisplay = hasOverride ? "${protocol} (override)" : protocol def lastSeenMs = data?.lastSeen ? (data.lastSeen as Long) : 0 def lastSeenStr = lastSeenMs ? formatTimeAgo(lastSeenMs) : "Never" def avgRawMin = data?.userInterval ? (data.userInterval as Long) : data?.avgInterval ? (data.avgInterval as Long) : 999999 def avgIntStr = data?.userInterval ? formatInterval(data.userInterval) + " (manual)" : data?.avgInterval ? formatInterval(data.avgInterval) : "Learning..." def h = state.health?.get(device.id) ?: "Pending" def healthOrder = snoozed ? 99 : (h == "Offline" ? 1 : h == "Poor" ? 2 : h == "Fair" ? 3 : h == "Good" ? 4 : h == "Excellent" ? 5 : 6) def stateInfo = getCurrentStateDisplay(device) def stateDisplay = stateInfo ? formatStateDisplay(stateInfo) : "—" def stateOrderVal = stateInfo ? stateInfo.label.toLowerCase() : "zzz" def tracked = state.stateHistory?.get(device.id as String) def lastChangedMs = tracked?.lastChanged ? (tracked.lastChanged as Long) : 0 def lastChangedStr = lastChangedMs ? formatTimeAgo(lastChangedMs) : "—" def loc = getDeviceLocation(device.id) ?: "—" rowNum++ def deviceLink = hubIp ? "${device.displayName}" : device.displayName table += "" table += "" table += "" table += "" table += "" table += "" table += "" def pingDisplay = getPingStatusDisplay(device.id) table += "" table += "" table += "" table += "" } table += "
DeviceProtocolHealthStateState ChangedLast Check-inAvg Check-inVerificationLocation
${deviceLink}${protocolDisplay}${getHealthDisplay(device)}${stateDisplay}${lastChangedStr}${lastSeenStr}${avgIntStr}${pingDisplay}${loc}
" paragraph rawHtml: true, """ ${hubIp ? "
⚠ 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.
" def bannerHtml = "" groups.each { srcHub, counts -> def worstColor = counts.offline > 0 ? "#991b1b" : counts.poor > 0 ? "#c62828" : counts.fair > 0 ? "#ea580c" : "#16a34a" def worstLabel = counts.offline > 0 ? "💀 Offline devices present" : counts.poor > 0 ? "🔴 Poor devices present" : counts.fair > 0 ? "🟠 Fair devices present" : "🟢 All healthy" bannerHtml += "
" bannerHtml += "${srcHub}  ·  ${counts.total} device(s)  ·  ${worstLabel}
" if (counts.offline > 0) bannerHtml += "💀 Offline: ${counts.offline}  " if (counts.poor > 0) bannerHtml += "🔴 Poor: ${counts.poor}  " if (counts.fair > 0) bannerHtml += "🟠 Fair: ${counts.fair}  " if (counts.good > 0) bannerHtml += "🟢 Good: ${counts.good}  " if (counts.excellent > 0) bannerHtml += "🟢 Excellent: ${counts.excellent}  " if (counts.pending > 0) bannerHtml += "⏳ Pending: ${counts.pending}  " bannerHtml += "
" } paragraph rawHtml: true, bannerHtml def table = "" table += "" table += "" table += "" table += "" table += "" table += "" table += "" table += "" def sorted = devList.sort { a, b -> def srcA = getHubMeshSourceHub(a) def srcB = getHubMeshSourceHub(b) if (srcA != srcB) return srcA <=> srcB def healthPriority = ["Offline": 1, "Poor": 2, "Fair": 3, "Good": 4, "Excellent": 5, "Pending": 6] def hA = state.health?.get(a.id) ?: "Pending" def hB = state.health?.get(b.id) ?: "Pending" return (healthPriority[hA] ?: 6) <=> (healthPriority[hB] ?: 6) } def rowNum = 0 sorted.each { device -> def data = state.history?.get(device.id) def protocol = getProtocol(device) def srcHub = getHubMeshSourceHub(device) def lastSeen = data?.lastSeen ? formatTimeAgo(data.lastSeen) : "Never" def rowBg = (rowNum % 2 == 0) ? "#ffffff" : "#ebebeb" def stateInfo = getCurrentStateDisplay(device) def stateDisplay = stateInfo ? formatStateDisplay(stateInfo) : "—" def deviceLink = hubIp ? "${device.displayName}" : device.displayName rowNum++ table += "" table += "" table += "" table += "" table += "" table += "" table += "" table += "" } table += "
DeviceSource HubProtocolHealthStateLast Check-in
${deviceLink}${srcHub}${protocol}${getHealthDisplay(device)}${stateDisplay}${lastSeen}
" if (hubIp) paragraph "⚠ Device links are accessible on your local network only." paragraph "
${table}
" } } } // ============================================================ // ===================== PROBLEM DEVICES PAGE ================ // ============================================================ def problemDevicesPage() { dynamicPage(name: "problemDevicesPage", title: "Problem Devices & Verification", install: false) { def allDevs = getAllMonitoredDevices().findAll { getProtocol(it) != "Unknown" } def hubIp = location?.hub?.localIP ?: "" section("") { href(name: "toForceScanFromProblems", page: "forceScanPage", title: "🔄 Force Scan Now") } def problems = allDevs.findAll { device -> def h = state.health?.get(device.id) ?: "Pending" h in ["Offline", "Poor", "Fair"] && !isDeviceSnoozed(device.id as String) }.sort { a, b -> def pri = ["Offline": 1, "Poor": 2, "Fair": 3] def pA = pri[state.health?.get(a.id)] ?: 4 def pB = pri[state.health?.get(b.id)] ?: 4 if (pA != pB) return pA <=> pB return a.displayName <=> b.displayName } def unverifiable = allDevs.findAll { getPingStatus(it.id) == "unverifiable" && !(state.health?.get(it.id) in ["Excellent", "Good", "Pending"]) } .sort { a, b -> a.displayName <=> b.displayName } def verified = allDevs.findAll { getPingStatus(it.id) == "verified" }.size() def declared = allDevs.findAll { getPingStatus(it.id) == "declared" }.size() def unverCount = unverifiable.size() def unknownCount = allDevs.findAll { getPingStatus(it.id) == "unknown" }.size() section("") { def offCount = problems.count { state.health?.get(it.id) == "Offline" } def poorCount = problems.count { state.health?.get(it.id) == "Poor" } def fairCount = problems.count { state.health?.get(it.id) == "Fair" } paragraph "Active Issues: " + "💀 ${offCount} Offline  |  " + "🔴 ${poorCount} Poor  |  " + "🟠 ${fairCount} Fair" + "
Verification: " + "✅ ${verified} Verified  |  " + "🔄 ${declared} Declared  |  " + "⚠ ${unverCount} Unverifiable" } section("Active Issues") { if (!problems) { paragraph "✅ No problem devices — all monitored devices are healthy." } else { def table = "" table += "" table += "" table += "" table += "" table += "" table += "" table += "" def rowNum = 0 problems.each { device -> def data = state.history?.get(device.id) def lastSeen = data?.lastSeen ? formatTimeAgo(data.lastSeen) : "Never" def stateInfo = getCurrentStateDisplay(device) def stateDisplay = stateInfo ? formatStateDisplay(stateInfo) : "—" def pingDisp = getPingStatusDisplay(device.id) def loc = getDeviceLocation(device.id) def locTag = loc ? " 🏷️ ${loc}" : "" def rowBg = (rowNum % 2 == 0) ? "#ffffff" : "#ebebeb" def deviceLink = hubIp ? "${device.displayName}" : device.displayName rowNum++ table += "" table += "" table += "" table += "" table += "" table += "" table += "" } table += "
DeviceHealthStateLast Check-inVerification
${deviceLink}${locTag}${getHealthDisplay(device)}${stateDisplay}${lastSeen}${pingDisp}
" 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.
" def table = "" table += "" table += "" table += "" table += "" table += "" table += "" def rowNum = 0 unverifiable.each { device -> def h = state.health?.get(device.id) ?: "Pending" def protocol = getProtocol(device) def cap = state.deviceCapabilities?.get(device.id as String) ?: [:] def attempts = cap.pingFailed ?: 0 def loc = getDeviceLocation(device.id) def locTag = loc ? " 🏷️ ${loc}" : "" def rowBg = (rowNum % 2 == 0) ? "#ffffff" : "#ebebeb" def deviceLink = hubIp ? "${device.displayName}" : device.displayName rowNum++ table += "" table += "" table += "" table += "" table += "" table += "" } table += "
DeviceProtocolHealthFailed Attempts
${deviceLink}${locTag}${protocol}${getHealthDisplay(device)}${attempts}
" if (hubIp) paragraph "⚠ Device links are accessible on your local network only." paragraph "
${table}
" } } section("Verification Summary", hideable: true, hidden: true) { paragraph "
" + "✅ 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.
" if (unknownCount > 0) { def unknownDevs = allDevs.findAll { getPingStatus(it.id) == "unknown" } .sort { a, b -> a.displayName <=> b.displayName } def unknownList = unknownDevs.collect { device -> def protocol = getProtocol(device) "${protocol} ${device.displayName}" }.join("
") paragraph "
" + "❓ Not yet scanned (${unknownCount}):
" + "${unknownList}

" + "These will be classified after the next scan. Run Force Scan to update immediately.
" } } } } // ============================================================ // ===================== PROTOCOL OVERRIDE PAGE ============== // ============================================================ def protocolOverridePage() { def allDevices = getAllMonitoredDevices() def protocolDevList = allDevices .findAll { device -> def hasOverride = settings["protocolOverride_${device.id}"] && settings["protocolOverride_${device.id}"] != "Auto-detect" def rawProtocol = getRawProtocol(device) hasOverride || isUnresolvableProtocol(rawProtocol) } .sort { a, b -> a.displayName.trim() <=> b.displayName.trim() } def stateDevList = allDevices .findAll { device -> def hasOverride = settings["stateAttrOverride_${device.id}"] && settings["stateAttrOverride_${device.id}"] != "Auto-detect" hasOverride || shouldShowStateOverride(device) } .sort { a, b -> a.displayName.trim() <=> b.displayName.trim() } dynamicPage(name: "protocolOverridePage", title: "🔧 Device Overrides", install: false) { section("") { paragraph "
" + "🔀 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.
" } if (!protocolDevList || protocolDevList.size() == 0) { section("") { paragraph "✅ No Hub Mesh, LAN, Virtual, or Hub Variable devices found — no protocol overrides needed." } } else { section("Unidentified / Overridden Devices (${protocolDevList.size()})") { protocolDevList.each { device -> def currentProtocol = getProtocol(device) def currentOverride = settings["protocolOverride_${device.id}"] ?: "Auto-detect" def isOverridden = currentOverride != "Auto-detect" def statusDisplay = isOverridden ? "⚙️ Override Active: ${currentProtocol}" : "Auto-detected: ${currentProtocol}" input "protocolOverride_${device.id}", "enum", title: "${device.displayName} — ${statusDisplay}", options: ["Auto-detect", "Zigbee", "Z-Wave", "Matter", "Hub Mesh (Zigbee)", "Hub Mesh (Z-Wave)", "Hub Mesh (Matter)", "Hub Mesh", "LAN", "Virtual", "Hub Variable"], defaultValue: currentOverride, required: false, width: 6 } } } section("") { paragraph "
" + "📌 State Attribute Overrides
" + "Pin a specific attribute per device when the app picks the wrong one to display in the Current State column.
" } if (!stateDevList || stateDevList.size() == 0) { section("") { paragraph "✅ No devices with overrideable state attributes found." } } else { section("Devices with Overrideable State Attributes (${stateDevList.size()})") { stateDevList.each { device -> def currentOverride = settings["stateAttrOverride_${device.id}"] ?: "Auto-detect" def autoResult = getCurrentStateDisplay(device) def attrs = getMeaningfulAttributes(device) def options = ["Auto-detect"] + attrs def overrideStateResult = currentOverride != "Auto-detect" ? getOverrideStateDisplay(device, currentOverride) : null def overrideValueDisplay = overrideStateResult ? formatStateDisplay(overrideStateResult) : "${currentOverride}" def currentDisplay = currentOverride == "Auto-detect" ? "Auto-detected: ${autoResult ? formatStateDisplay(autoResult) : "—"}" : "⚙️ Override Active: ${overrideValueDisplay}" input "stateAttrOverride_${device.id}", "enum", title: "${device.displayName} — ${currentDisplay}", options: options, defaultValue: currentOverride, required: false, width: 6 } } } section("") { paragraph "Tap Done to save. Changes take effect immediately on the next page load." } } } // ============================================================ // ===================== SNOOZE MANAGE PAGE ================== // ============================================================ def snoozeManagePage() { app.removeSetting("devicesToSnooze") app.removeSetting("devicesToUnsnooze") app.updateSetting("confirmSnooze", [value: false, type: "bool"]) app.updateSetting("confirmUnsnooze", [value: false, type: "bool"]) def devList = getAllMonitoredDevices().findAll { getProtocol(it) != "Unknown" }.sort { a, b -> a.displayName.trim() <=> b.displayName.trim() } def snoozedList = devList.findAll { isDeviceSnoozed(it.id as String) } def activeList = devList.findAll { !isDeviceSnoozed(it.id as String) } dynamicPage(name: "snoozeManagePage", title: "😴 Manage Snoozed Devices", install: false) { section("Snooze Devices") { paragraph "Select devices to snooze for ${settings?.snoozeDurationHours ?: 24} hours." if (activeList) { input "devicesToSnooze", "enum", title: "Select devices to snooze:", options: activeList.collectEntries { [(it.id): "${it.displayName} (${state.health?.get(it.id) ?: 'Pending'})"] }.sort { a, b -> a.value <=> b.value }, multiple: true, required: false } else { paragraph "All devices are currently snoozed." } } if (activeList) { section() { input "confirmSnooze", "bool", title: "Confirm — snooze selected devices", defaultValue: false, submitOnChange: true } if (settings?.confirmSnooze == true) { section("Snooze Result") { if (settings?.devicesToSnooze) { def count = 0 settings.devicesToSnooze.each { deviceId -> snoozeDevice(deviceId); count++ } app.updateSetting("confirmSnooze", [value: false, type: "bool"]) paragraph "✅ Snoozed ${count} device(s) for ${settings?.snoozeDurationHours ?: 24} hours." } else { app.updateSetting("confirmSnooze", [value: false, type: "bool"]) paragraph "No devices selected to snooze." } } } } section("Currently Snoozed") { if (snoozedList) { paragraph snoozedList.collect { device -> "😴 ${device.displayName} — ${formatSnoozeRemaining(device.id as String)}" }.join("\n") input "devicesToUnsnooze", "enum", title: "Select devices to unsnooze early:", options: snoozedList.collectEntries { [(it.id): "${it.displayName} (${formatSnoozeRemaining(it.id as String)})"] }.sort { a, b -> a.value <=> b.value }, multiple: true, required: false } else { paragraph "No devices are currently snoozed." } } if (snoozedList) { section() { input "confirmUnsnooze", "bool", title: "Confirm — unsnooze selected devices", defaultValue: false, submitOnChange: true } if (settings?.confirmUnsnooze == true) { section("Unsnooze Result") { if (settings?.devicesToUnsnooze) { def count = 0 settings.devicesToUnsnooze.each { deviceId -> unsnoozeDevice(deviceId); count++ } app.updateSetting("confirmUnsnooze", [value: false, type: "bool"]) paragraph "✅ Unsnoozed ${count} device(s)." } else { app.updateSetting("confirmUnsnooze", [value: false, type: "bool"]) paragraph "No devices selected to unsnooze." } } } } } } // ============================================================ // ===================== FORCE SCAN PAGE ===================== // ============================================================ def forceScanPage() { scanAllDevices() dynamicPage(name: "forceScanPage", title: "Force Scan", install: false) { section("Scan Started") { def devList = getAllMonitoredDevices().findAll { getProtocol(it) != "Unknown" } def totalDevices = devList.size() def chunkSize = totalDevices > 200 ? 25 : 40 def intervalStr = settings?.scanInterval ?: "3" def intervalMinutes = (intervalStr.toFloat() * 60).toInteger() def minGate = Math.min(intervalMinutes * 0.5, 30.0).toInteger() paragraph "✅ Scan started — ${devList.size()} device(s) processing in the background. " + "Health scores and device states update progressively as batches complete.

" + "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.
" } section("Batch Scanning") { paragraph rawHtml: true, "
" + "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.
" } section("🏷️ Location Assignment") { paragraph rawHtml: true, "
" + "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, "
" + "
" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "
HealthMeaning
⏳ Pending (n/3 samples)Learning — sample count shown inline until 3 are collected
🟢 ExcellentChecking in within 1.5× of baseline
🟢 GoodChecking in within 3× of baseline
🟠 FairChecking in within 6× of baseline
🔴 PoorChecking in beyond 6× of baseline
💀 OfflineNo activity for configured threshold (default ${settings?.offlineThresholdHours ?: 168}h). Low activity unverifiable devices are capped at Poor.
😴 SnoozedExcluded from notifications for a set duration
ℹ️ Low ActivityMonitored 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.
" } section("🔄 Verification (Ping / Refresh / State)") { paragraph rawHtml: true, "
" + "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
" } } }