/* groovylint-disable CompileStatic, DuplicateNumberLiteral, DuplicateStringLiteral, ImplicitClosureParameter, ImplicitReturnStatement, LineLength, MethodParameterTypeRequired, MethodSize, NestedBlockDepth, NoDef, PublicMethodsBeforeNonPublicMethods, UnnecessaryGetter, VariableTypeRequired */ /** * Device Health Status - application for Hubitat Elevation hub * * https://community.hubitat.com/t/project-alpha-device-health-status/111817 * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except * in compliance with the License. You may obtain a copy of the License at: * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License * for the specific language governing permissions and limitations under the License. * * Based on "Light Usage Table" Hubitat sample code by Bruce Ravenel * * ver. 1.0.0 2023-02-03 kkossev - first version: 'Light Usage Table' sample app code modification * ver. 1.0.1 2023-02-03 kkossev - added powerSource, battery, model, manufacturer, driver name; added option to skip the 'capability.healthCheck' filtering; * ver. 1.0.2 2023-02-03 FriedCheese2006 - Tweaks to Install Process * ver. 1.0.3 2023-02-03 tonesto7 - Added last activity date; kkossev - importUrl; documentationLink; app version; debug and info logs options; added controller type, driver type; added an option to filter battery-powered only devices, hide poweSource column; filterHealthCheckOnly bug fix; * -last activity thresholds and color options; battery threshold option; catching some exceptions when a device is deleted from HE, but was present in the list; added device status * ver. 1.0.4 2023-02-06 kkossev - added 'Device Status' red/green colors; added hideModelAndManufacturerColumns and hideVirtualAndUnknownDevices filtering options; app instance name can be changed; added Presence column * ver. 1.0.5 2023-02-08 kkossev - added toggle "Show only offline (INACTIVE / not present) devices" * ver. 1.0.6 2023-02-15 kkossev - IntelliJ lint; merged Tonesto7 pull request; * ver. 1.0.7 2023-02-16 FriedCheese2006 - Added DataTables for enhance table sorting/searching * ver. 1.0.8 2023-11-12 kkossev - added "MAT" controllerType * ver. 1.1.0 2024-05-33 kkossev - Groovy linting; * ver. 1.1.1 2025-02-02 kkossev - added lastBattery option (default:disabled); added hideDisabledDevices option (default:enabled) * * TODO: option to calculate and show the time since the last activity in (D, H, M, S) */ import groovy.transform.Field final String version() { '1.1.1' } final String timeStamp() { '2025/02/02 3:01 PM' } @Field static final Boolean debug = false definition( name: 'Device Health Status', namespace: 'kkossev', author: 'Krassimir Kossev', description: 'Device Health Status', category: 'Utility', iconUrl: '', iconX2Url: '', importUrl: 'https://raw.githubusercontent.com/kkossev/Hubitat/development/Apps/Device%20Health%20Status.groovy', documentationLink: 'https://community.hubitat.com/t/alpha-device-health-status/111817/1' ) preferences { page(name: 'mainPage') } /* groovylint-disable-next-line MethodReturnTypeRequired */ def mainPage() { if (state.devices == null) { state.devices = [:] } if (state.devicesList == null) { state.devicesList = [] } if (app.getInstallationState() == 'COMPLETE') { hideDevices = true } else { hideDevices = false } dynamicPage(name: 'mainPage', title: "Device Health Status (app ver. ${driverVersionAndTimeStamp()})", uninstall: true, install: true) { section('Device Selection', hideable: true, hidden: hideDevices) { input name: 'devices', type: settings?.selectHealthCheckOnly == true ? 'capability.healthCheck' : 'capability.*', title: 'Select devices', multiple: true, submitOnChange: true, width: 4 logDebug 'Device Selection : start' devices.each { dev -> if (!state.devices["$dev.id"]) { //logDebug "Device Selection : new device ${state.devices["$dev.id"]}" } else { //logDebug "Device Selection : existing device ${state.devices["$dev.id"]}" } try { if (dev != null && dev?.status != null) { //log.trace 'status = ${dev.status} (device ${state.devices["$dev.id"]})' def hasBattery = dev.capabilities.find { it.toString().contains('Battery') } ? true : false def hasPowerSource = dev.capabilities.find { it.toString().contains('PowerSource') } ? true : false //log.trace "$dev.name hasBattery=${hasBattery} hasPowerSource=${hasPowerSource} isDisabled=${isDisabled}" state.devices["$dev.id"] = [ healthStatus : dev.currentValue('healthStatus'), hasPowerSource: hasPowerSource, hasBattery : hasBattery ] state.devicesList += dev.id } else { logWarn "dev is null? state.devices[dev.id] is ${state.devices["$dev.id"]}" } } catch (e) { logWarn "exception catched when procesing device ${dev.id}" } } if (devices) { if (devices.id.sort() != state.devicesList.sort()) { //something was removed logDebug 'Device Selection : something was changed' state.devicesList = devices.id Map newState = [:] devices.each { d -> newState["$d.id"] = state.devices["$d.id"] } state.devices = newState } else { logDebug 'Device Selection : no changes' } } else { logWarn "Device Selection : devices = ${devices}" } logDebug 'Device Selection : start' } // section "Device Selection" if (hideDevices) { section { updated() paragraph '' input name: 'showOfflineOnly', type: 'bool', title: 'Show only offline (INACTIVE / not present) devices', submitOnChange: true, defaultValue: false paragraph '' paragraph displayTable() input 'refresh', 'button', title: 'Refresh Table', width: 2 } section('Options', hideable: true, hidden: hideDevices) { label title: 'Change this Device Health Status app instance name:', submitOnChange: true, required: false paragraph '' input('logEnable', 'bool', title: 'Debug logging.', defaultValue: false, required: false) input('txtEnable', 'bool', title: 'Description text logging.', defaultValue: false, required: false) paragraph '' paragraph 'Device selection options:' input name: 'selectHealthCheckOnly', type: 'bool', title: "Select only devices that have 'Healtch Check' capability", submitOnChange: true, defaultValue: false paragraph '' paragraph 'Table filtering options: columns :' input name: 'hidePowerSourceColumn', type: 'bool', title: 'Hide powerSource column', submitOnChange: true, defaultValue: false input name: 'hideLastActivityAtColumn', type: 'bool', title: 'Hide LastActivityAt column', submitOnChange: true, defaultValue: false input name: 'hideLastBatteryColumn', type: 'bool', title: 'Hide LastBattery column', submitOnChange: true, defaultValue: true input name: 'hideModelAndManufacturerColumns', type: 'bool', title: 'Hide Model and Manufacturer columns', submitOnChange: true, defaultValue: false input name: 'hidePresenceColumn', type: 'bool', title: 'Hide Presence column (the one that we are trying to depricate)', submitOnChange: true, defaultValue: true paragraph '' paragraph 'Table filtering options: rows :' input name: 'hideNotBatteryDevices', type: 'bool', title: 'Hide not battery-powered devices', submitOnChange: true, defaultValue: false input name: 'hideNoHealthStatusAttributeDevices', type: 'bool', title: 'Hide devices without healthStatus attribute', submitOnChange: true, defaultValue: false input name: 'hideVirtualAndUnknownDevices', type: 'bool', title: 'Hide virtual/unknown type devices', submitOnChange: true, defaultValue: false input name: 'hideDisabledDevices', type: 'bool', title: 'Hide disabled devices', submitOnChange: true, defaultValue: true paragraph '' paragraph 'Thresholds :' input name: 'lastActivityGreen', type: 'number', title: "Devices w/ lastActivity less than $lastActivityGreen hours will be shown in green", submitOnChange: true, defaultValue: 9 input name: 'lastActivityRed', type: 'number', title: "Devices w/ lastActivity more than $lastActivityRed hours will be shown in red", submitOnChange: true, defaultValue: 25 input name: 'batteryLowThreshold', type: 'number', title: "Devices w/ Battery percentage below $batteryLowThreshold % will be shown in red", submitOnChange: true, defaultValue: 33 } } else { section('CLICK DONE TO INSTALL APP AFTER SELECTING DEVICES') { paragraph '' } } } } String displayTable() { String str = "" //Import DataTables library str += "" str += "" str += '
" + "" + '' + '' + (settings?.hideLastBatteryColumn != true ? '' : '') + (settings?.hideLastActivityAtColumn != true ? '' : '') + '' + (settings?.hidePresenceColumn != true ? '' : '') + (settings?.hidePowerSourceColumn != true ? '' : '') + (settings?.hideModelAndManufacturerColumns != true ? '' : '') + (settings?.hideModelAndManufacturerColumns != true ? '' : '') + '' + '' + '' + //End header row; start table body '' def devicesSorted = devices try { devices.sort { it?.displayName.toLowerCase() } } catch (e) { logWarn "catched exception while sorting devices : ${e} " return 'INTERNAL ERROR, please send the debug logs to the developer' } devices = devicesSorted devices.sort { it?.displayName.toLowerCase() }.each { dev -> //log.trace "processing device ${dev.id} ${dev.displayName} isDisabled=${dev.disabled} settings?.hideDisabledDevices=${settings?.hideDisabledDevices}" def devData = dev.getData() def devType = dev.getTypeName() if (settings?.hideNotBatteryDevices == true && state.devices["$dev.id"].hasBattery == false) { //logDebug "SKIPPING dev.id=${dev.id} w/o Battery " } else if (settings?.hideNoHealthStatusAttributeDevices == true && state.devices["$dev.id"].healthStatus == null) { //logDebug "SKIPPING dev.id=${dev.id} w/o healthStatus" } else if (settings?.hideVirtualAndUnknownDevices == true && !(dev.controllerType in ['ZGB', 'ZWV', 'LNK', 'MAT'])) { //logDebug "SKIPPING dev.id=${dev.id} VirtualAndUnknownDevices ${dev.controllerType}" } else if (settings?.hideDisabledDevices == true && dev.disabled == true) { logDebug "SKIPPING dev.id=${dev.id} disabled" } else { // String devLink = "$dev" def healthColor = dev.currentHealthStatus == null ? 'black' : dev.currentHealthStatus == 'online' ? 'green' : 'red' def healthStatus = dev.currentHealthStatus ?: 'n/a' def lastBattery = dev.currentBattery ?: 'n/a' def readableUTCDate = (dev.lastActivity ?: 'n/a').toString().tokenize('+')[0] def lastActivity = 'n/a' def lastActivityColor = 'black' def batteryPercentageColor = 'black' def statusColor = (dev.status ?: 'n/a') == 'INACTIVE' ? 'red' : (dev.status ?: 'n/a') == 'ACTIVE' ? 'green' : 'black' def presenceColor = (dev.currentPresence ?: 'n/a') == 'not present' ? 'red' : (dev.currentPresence ?: 'n/a') == 'present' ? 'green' : 'black' def status = dev.status ?: 'n/a' if (dev.disabled == true) { status += ' (DISABLED)' } if (readableUTCDate != 'n/a') { Date date = Date.parse('yyyy-MM-dd HH:mm:ss', readableUTCDate) lastActivity = new Date(date.getTime() + TimeZone.getDefault().getOffset(date.getTime())) def now = new Date() long diff = now.getTime() - date.getTime() long diffHours = diff / (60 * 60 * 1000) if (diffHours < settings?.lastActivityGreen && healthStatus != 'offline') { lastActivityColor = 'green' } else if (diffHours >= settings?.lastActivityRed) { lastActivityColor = 'red' } else { lastActivityColor = 'black' } } if (dev.currentBattery == null && dev.currentPowerSource == 'battery') { batteryPercentageColor = 'red' } else if (healthStatus == 'online' && lastActivityColor != 'red' && dev.currentPowerSource == 'battery' && (dev.currentBattery as int) >= settings?.batteryLowThreshold) { batteryPercentageColor = 'green' } else if (healthStatus == 'online' && lastActivityColor == 'green' && dev.currentPowerSource == 'battery' && (dev.currentBattery as int) < settings?.batteryLowThreshold) { batteryPercentageColor = 'red' } else { batteryPercentageColor = 'black' // not sure if the battery percentage remaining is accurate ... } if (settings.showOfflineOnly == true && (healthStatus == 'online' || dev.status == 'ACTIVE')) { //logDebug "SKIPPING dev.id=${dev.id} offline" } else { //lastActivity = lastActivity.tokenize( '+' )[0] batteryLowThreshold str += "" + "" + "" + (settings?.hideLastBatteryColumn != true ? "" : '') + (settings?.hideLastActivityAtColumn != true ? "" : '') + "" + (settings?.hidePresenceColumn != true ? "" : '') + (settings?.hidePowerSourceColumn != true ? "" : '') + (settings?.hideModelAndManufacturerColumns != true ? "" : '') + (settings?.hideModelAndManufacturerColumns != true ? "" : '') + "" + "" + "" //+ } } } // for each device str += '
Device
Name
Health
Status
Battery
%
Last
Battery
Last
Activity
HE
Status
Presence
Attr.
Power
Source
Device
Model
Device
Manufacturer
Device
Type
Driver
Name
Driver
Type
$devLink$healthStatus${dev.currentBattery ?: 'n/a'}${dev.currentLastBattery ?: 'n/a'}${lastActivity}${status}${dev.currentPresence ?: 'n/a'}${dev.currentPowerSource ?: 'n/a'}${devData.model ?: 'n/a'}${devData.manufacturer ?: 'n/a'}${dev.controllerType ?: 'n/a'}${devType ?: 'n/a'}${dev.driverType ?: 'n/a'}
' //Use DataTable to process the table str += "" str } String buttonLink(String btnName, String linkText, color = '#1A77C9', font = '15px') { "
$linkText
" } void appButtonHandler(btn) { logDebug "appButtonHandler(${btn} start)" List toBeDel = [] if (btn == 'refresh') { state.devices.each { k, v -> try { def dev = devices.find { "$it.id" == k } //logDebug "checking state.devices[${k}]" if ((dev.currentStatus ?: 'unknown') == 'ACTIVE') { //state.devices[k].refreshTime = now() } } catch (e) { logWarn "catched exception in appButtonHandler : ${e} " logWarn "problematic device has key=${k}" toBeDel += k } } } toBeDel.each { k -> logDebug "TODO: delete ${toBeDel} from state.devices list .." } logDebug "appButtonHandler(${btn} exited)" } /* groovylint-disable-next-line UnusedPrivateMethod */ private void updateTableOnEvent() { logDebug 'updateTableOnEvent' } void healthStatusOnlineHandler(evt) { logDebug "healthStatusOnlineHandler evt.name=${evt.name} evt.value=${evt.value}" runIn(1, 'updateTableOnEvent'/*, [overwrite: true, data: evt]*/) } void healthStatusOfflineHandler(evt) { logDebug "healthStatusOfflineHandler evt.name=${evt.name} evt.value=${evt.value}" runIn(1, 'updateTableOnEvent'/*, [overwrite: true, data: evt]*/) } String driverVersionAndTimeStamp() { version() + ' ' + timeStamp().split(' ')[0] } void logDebug(msg) { if (logEnable) { log.debug(msg) } } void logWarn(msg) { if (logEnable) { log.warn(msg) } } void logInfo(msg) { if (txtEnable) { log.info(msg) } } void updated() { logDebug 'updated()' unsubscribe() initialize() } void installed() { logInfo 'installed()' } void initialize() { logDebug 'initialize()' try { subscribe(devices, 'healthStatus.online', healthStatusOnlineHandler) subscribe(devices, 'healthStatus.offline', healthStatusOfflineHandler) } catch (e) { logWarn "catched exception while processing initialize() : ${e} " } }