/**
* Rule Machine Manager
*
* 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.
*
* View the full changelog here:
* https://raw.githubusercontent.com/joshlobe/hubitat/main/rule_machine_manager/changelog.txt
*/
// Define application
definition(
name: "Rule Machine Manager",
namespace: "ruleMachineManager",
author: "Josh Lobe",
description: "Visual Interface for Managing Rules from Various Applications.",
category: "Convenience",
importUrl: "https://raw.githubusercontent.com/joshlobe/hubitat/main/rule_machine_manager/rule_machine_manager.groovy",
iconUrl: "",
iconX2Url: ""
)
// Import helpers
import hubitat.helper.RMUtils
import hubitat.helper.ColorUtils
import groovy.json.JsonSlurper
import groovy.json.JsonOutput
import groovyx.net.http.HttpResponseException
// Define versions
def version() { "3.2" }
def js_version() { "3.2" }
def css_version() { "3.2" }
// Define globals (note: do not use def (scope))
ruleMap = [:]
ruleMapNames = [:]
newRulesCheck = []
populateRuleList()
// Begin preferences
preferences {
// Define mappings to handle javascript requests
mappings { path("/updateSettings") { action: [ POST: "updateSettings" ] } }
// Begin page
page(name: "mainPage", install: true, uninstall: true) {
section {
/**************************************************
// Begin page html
**************************************************/
if( logDebugEnable ) log.debug "Beginning Page HTML..."
html = ""
/**************************************************
// Page notices (new/deleted rules, new/deleted machines)
**************************************************/
// Check if there are user rules defined
userRules = settings?.userArray ? settings?.userArray : ''
// If user rules are found
if( userRules != '' ) {
if( logDebugEnable ) log.debug "Using User Created Rules for Containers..."
// Decode rules
userRules = new JsonSlurper().parseText( userRules )
// Check for newly supported rule machine types
checkNewMachines = checkForNewMachines( userRules )
if( checkNewMachines ) { html += checkNewMachines }
// Check for deleted rule machine types
checkDeletedMachines = checkForDeletedMachines( userRules )
if( checkDeletedMachines ) { html += checkDeletedMachines }
// Check for new rules
checkNewRules = checkForNewRules( userRules )
if( checkNewRules ) { html += checkNewRules }
// Check for deleted rules
checkDeletedRules = checkForDeletedRules( userRules )
if( checkDeletedRules ) { html += checkDeletedRules }
// If there are new rules not yet saved in RMM, add them to the original rules container
userRules.containers.each{
if( it.slug == "original-rules" ) {
def thisRules = it.rules
newRulesCheck.each{ thisRules.push( it.key ) }
}
}
}
// Else use default rules
else {
if( logDebugEnable ) log.debug "Using Default Rules for Containers..."
// Decode rules
buildRules = defaultUserArrayText()
userRules = new JsonSlurper().parseText( buildRules )
}
/**************************************************
// Page header
**************************************************/
// Define variables
welcome_nag = userRules.welcome_nag ? userRules.welcome_nag : 'true'
ruleMachines = userRules.containsKey( 'rule_machines' ) ? JsonOutput.toJson( userRules.rule_machines ) : JsonOutput.toJson( new JsonSlurper().parseText( defaultUserArrayText() ).rule_machines )
checkMachines = userRules.containsKey( 'check_machines' ) ? JsonOutput.toJson( userRules.check_machines ) : JsonOutput.toJson( new JsonSlurper().parseText( defaultUserArrayText() ).check_machines )
activeMachines = userRules && userRules.hide_machines == 'true' ? 'active' : ''
machinesText = userRules && userRules.hide_machines == 'true' ? 'Show Machine Names' : 'Hide Machine Names'
activeCounts = userRules && userRules.hide_counts == 'true' ? 'active' : ''
countsText = userRules && userRules.hide_counts == 'true' ? 'Show Counts' : 'Hide Counts'
activeFilters = userRules && userRules.hide_filters == 'true' ? 'active' : ''
filtersText = userRules && userRules.hide_filters == 'true' ? 'Show Filters' : 'Hide Filters'
hideGlobalFilter = userRules && userRules.hide_filters == 'true' ? 'display:none;' : ''
// Begin header area
html += "
"
html += "
"
html += "add_circleCreate Container"
html += ""
html += "
"
html += "
"
// Define hidden inputs
html += ""
html += ""
html += ""
html += ""
html += ""
html += ""
html += ""
// Create hubitat hidden form input and variables
html += ''
html += ''
html += ''
// Header area icons
html += "settingsApp Options"
html += "helpHelp"
html += "rocketQuick Save"
html += "ballot${countsText}"
html += "table_rows${filtersText}"
html += "data_array${machinesText}"
html += "check_circleDone"
html += "
"
html += "
"
/**************************************************
// Page containers
**************************************************/
if( logDebugEnable ) log.debug "Creating Page Containers..."
// Create temp array for duplication comparisons
tempArray = []
// Begin page containers
html += "
"
// Loop each container
userRules.containers.each{
if( logDebugEnable ) log.debug "Creating Container ${it.name}..."
// These are applied to the main container, and must be defined first
container_color = ( it.container_color && it.container_color != '' && it.container_color != 'null' ) ? it.container_color : '#FFFFFF'
container_opacity = ( it.container_opacity && it.container_opacity != '' && it.container_opacity != 'null' ) ? it.container_opacity : '1'
// Convert colors to rgb
rgb = hubitat.helper.ColorUtils.hexToRGB( container_color )
rgba_string = "rgba(" + rgb[0] + ", " + rgb[1] + ", " + rgb[2] + ", " + container_opacity + ")"
// Begin this container
html += "
"
// Get values from database
title_color = ( it.title_color && it.title_color != '' && it.title_color != 'null' ) ? it.title_color : '#000'
title_opacity = ( it.title_opacity && it.title_opacity != '' && it.title_opacity != 'null' ) ? it.title_opacity : '1'
title_bold = ( it.title_bold && it.title_bold == 'true' ) ? 'true' : 'false'
font_weight = ( it.title_bold && it.title_bold == 'true' ) ? 'bold' : 'normal'
// Set hidden input fields for this container
html += ""
html += ""
html += ""
html += ""
html += ""
// Container title area
html += "
"
html += '
'
// Container title
html += "${it.name}"
html += ""
// Add rule count (hide if user selected )
hide_counts = userRules.hide_counts == 'true' ? 'display: none;' : ''
html += ""
// Add container filter (hide if user selected )
hide_filters = userRules.hide_filters == 'true' ? 'display: none;' : ''
html += ""
html += '
'
html += '
'
// Add container options icon
html += ""
html += "settings"
html += "Container Options"
html += ""
// Add Expand/Collapse container icon
toggle_icon = it.visible == true ? 'file_upload' : 'file_download'
toggle_text = it.visible == true ? 'Collapse' : 'Expand'
html += ""
html += "${toggle_icon}"
html += "${toggle_text}"
html += ""
// Add move icon
html += ""
html += "open_with"
html += "Move"
html += ""
// Begin submenu dropdown menu
html += '
'
// Add edit container
html += '
edit Edit Container
'
// Add sort icons
html += '
arrow_downward Sort Asc
'
html += '
arrow_upward Sort Desc
'
// Add delete icon, but not to original rules container
if( it.slug != 'original-rules' ) {
html += '
delete Delete Container
'
}
html += '
'
html += '
'
html += "
"
/**************************************************
// Container rules
**************************************************/
if( logDebugEnable ) log.debug "Creating Rule List For Container ${it.name}..."
html += "
"
// Loop each rule
it.rules.each{
// Setup vars
rule_id = it
// First ensure key exists (used when rules have been deleted from RM)
if( ruleMap.find{ it.key == rule_id.toInteger() } ) {
// Add this item to temp array (used later to determine if item is duplicate)
tempArray.push( it )
// Get rule attributes for paused and/or disabled
def getAtts = ruleMap[ rule_id.toInteger() ]
def name = getAtts?.name
def paused = getAtts?.paused == true ? ' (Paused)' : ''
def disabled = getAtts?.disabled == true ? ' (Disabled)' : ''
def ruleType = getAtts?.ruleType
def fontStyle = "color:${title_color};opacity:${title_opacity};"
def machineType = "[${ruleMapNames[ruleType]}]"
def machineCheckArray = userRules.rule_machines ? userRules.rule_machines.contains( ruleType ) : true
def listItemStyle = machineCheckArray == false ? 'display:none;' : ''
def machineHidden = machineCheckArray == false ? 'machineHidden' : ''
// Create list item
html += "
"
// Column left
html += '
'
// Rule name
html += "${name}${disabled}${machineType}"
html += '
'
// Column right
html += '
'
// Add edit rule
html += ""
html += "content_paste_goEdit Rule"
html += ''
// Add rule status
html += ""
html += "ballotView Rule Status"
html += ''
// Add rule logs
html += ""
html += "integration_instructionsView Rule Logs"
html += ''
// Add rule copy or delete
def copyDel = tempArray.count( rule_id ) > 1 ? 'delete' : 'copy'
html += ""
html += "${copyDel == 'copy' ? 'content_copy' : 'delete_outline'}"
html += "${copyDel == 'copy' ? 'Create' : 'Delete'} Duplicate Rule"
html += ""
html += '
'
html += "
"
}
}
html += "
"
if( logDebugEnable ) log.debug "Finished Creating Rule List for Container ${it.name}..."
html += "
'
/**************************************************
// Include scripts and styles
**************************************************/
// Include jquery
html += ""
html += ""
html += ""
// Include confirm overlay
html += ""
// Include material icons
html += ""
// Add scripts/styles for page
html += ""
html += ""
/**************************************************
// Page render html
**************************************************/
// Display page
paragraph "${html}"
paragraph ""
/**************************************************
// Page footer area
**************************************************/
if( logDebugEnable ) log.debug "Creating Page Footer..."
// Log file
paragraph "
Use the debug tool to generate information for the log file.
"
input "logDebugEnable", "bool", required: false, title: "Enable Debug Logging (auto off in 15 minutes)", defaultValue: false
paragraph ""
// Footer area
grid = "
"
grid += "
"
grid += "
"
// Footer notes
grid += "NOTE: Remember to click \"Done\" after modifying any options or resetting the rules. "
grid += "GET HELP: "
grid += "Hubitat Community Thread "
grid += "
"
grid += "
"
grid += "
"
// Check if js and css files are stored locally
js_installed = 'Not Found in File Manager'
css_installed = 'Not Found in File Manager'
try {
httpGet([ uri: "http://127.0.0.1:8080/local/rule_machine_manager.js", contentType: "text/html" ]) { resp ->
if (resp.success) { js_installed = 'Found in File Manager' }
}
}
catch (Exception e) { log.warn "Call to check js file in file manager failed: ${e.message}" }
try {
httpGet([ uri: "http://127.0.0.1:8080/local/rule_machine_manager.css", contentType: "text/html" ]) { resp ->
if (resp.success) { css_installed = 'Found in File Manager' }
}
}
catch (Exception e) { log.warn "Call to check css file in file manager failed: ${e.message}" }
// Footer details area
def getStateSpan = app.getInstallationState() == 'COMPLETE' ? 'Enabled' : 'Not Enabled'
def getTokenSpan = state.accessToken != null && state.accessToken != '' ? 'Enabled' : "Not Enabled"
grid += "
"
grid += "
App Version:
${version()}
"
grid += "
App State:
${getStateSpan}
"
grid += "
JS File:
${js_installed}
"
grid += "
CSS File:
${css_installed}
"
grid += "
oAuth:
${getTokenSpan}
"
grid += "
"
grid += "
"
grid += "
"
// Display footer
paragraph "${grid}"
if( logDebugEnable ) log.debug "Finished Page Footer..."
if( logDebugEnable ) log.debug "Finished Page HTML..."
}
}
}
// Define default container and array of rules not yet saved in the user settings
def defaultUserArrayText() {
// Build names for hidden input
buildNames = '['
if( ruleMapNames ) {
ruleMapNames.each{ buildNames += '"' + it.key + '",' }
buildNames = buildNames.substring( 0, buildNames.lastIndexOf( "," ) )
}
buildNames += ']'
// Build string of rule ids, remove trailing comma
buildRules = '['
if( ruleMap ) {
ruleMap.each{ buildRules += '"' + it.key + '",' }
buildRules = buildRules.substring( 0, buildRules.lastIndexOf( "," ) )
}
buildRules += ']'
// Create text string of plugin defaults
text = '{'
text += '"hide_counts":"false",'
text += '"hide_filters":"false",'
text += '"hide_machines":"false",'
text += '"welcome_nag":"true",'
text += '"rule_machines": ' + buildNames + ','
text += '"check_machines": ' + buildNames + ','
text += '"containers":[{'
text += '"name":"Original Rules",'
text += '"slug":"original-rules",'
text += '"title_color":"",'
text += '"title_opacity":"",'
text += '"title_bold":"",'
text += '"container_color":"",'
text += '"container_opacity":"",'
text += '"visible":true,'
text += '"rules": ' + buildRules
text += '}]'
text += '}'
return text
}
// Check for new rule machines
def checkForNewMachines( userRules ) {
// Get default and user values
defaults = new JsonSlurper().parseText( defaultUserArrayText() ).check_machines
saved = userRules.check_machines ? userRules.check_machines : ["ruleMachine"]
// Remove from defaults any values already saved
saved.each{ defaults -= it }
// If there are remaining values
if( defaults ) {
// Loop each removed machine type and make human readable message of machine types
defaultsString = ''
defaults.each { defaultsString += splitCamelCase( it ) + ', ' }
defaultsString = defaultsString.substring( 0, defaultsString.lastIndexOf( "," ) )
// Message
message = "
"
message += "notifications"
message += "New rule machine type found (" + defaultsString + ")! Please visit App Options -> Global Options to enable."
message += "
"
return message
}
else {
if( logDebugEnable ) log.debug "No Deleted Machines Found..."
return false;
}
}
// Check for deleted machines
def checkForDeletedMachines( userRules ) {
// Get default and user values
defaults = new JsonSlurper().parseText( defaultUserArrayText() ).check_machines
saved = userRules.check_machines ? userRules.check_machines : ["ruleMachine"]
// Loop each defaults and remove if checked
defaults.each{ saved -= it }
// If any machines remaining; they have been removed
if( saved ) {
// Loop each removed machine type and make human readable message of machine types
savedString = ''
saved.each { savedString += splitCamelCase( it ) + ', ' }
savedString = savedString.substring( 0, savedString.lastIndexOf( "," ) )
// Message
message = "
"
message += "notifications"
message += "A machine type has been removed (" + savedString + "). Please save the page options to update."
message += "
"
return message
}
else {
if( logDebugEnable ) log.debug "No New Machines Found..."
return false;
}
}
// Check for new rules
def checkForNewRules( userRules ) {
// Iterate user rules to build final array of keys
newRules = []
userRules.containers.each{ it.rules.each{ newRules.push( it.toString() ) } }
// Get any new rules that have been added
ruleMap.each{ if( ! newRules.contains( it.key.toString() ) ) { newRulesCheck.push( it ) } }
// If any new rules found, add notice
if( ! newRulesCheck.isEmpty() ) {
if( logDebugEnable ) log.debug "New Rules Found..."
// Build new rules found message
messageNew = "
"
messageNew += "notifications"
messageNew += "New rules (${newRulesCheck.size()}) have been discovered in the Rule Manager App and added to the \"Original Rules\" container. Please click the \"Done\" button to save after any modifications."
messageNew += "
NOTE: If the new rules are not visible in the original rules container; please visit App Options -> Global Options to ensure all rule machine types are displayed."
messageNew += "
"
return messageNew
}
// Else no new rules found
else {
if( logDebugEnable ) log.debug "No New Rules Found..."
return false
}
}
// Check for deleted rules
def checkForDeletedRules( userRules ) {
// Define final array, compare with new rules and extract deleted rules
deletedRules = []
userRules.containers.each{ it.rules.each{ deletedRules.push( it.toString() ) } }
ruleMap.each{ deletedRules.removeAll( it.key.toString() ) }
// Check what is left in this array against allowed rules; to allow duplicates through
ruleMap.each{ it.each{
// If this is a duplicate rule; it still exists in the main rm array
if( deletedRules.contains( it.key.toString() ) ) { deletedRules.remove( it.key.toString() ) }
}}
// If any deleted rules found, add notice
if( ! deletedRules.isEmpty() ) {
if( logDebugEnable ) log.debug "Deleted Rules Found..."
// Build deleted rules message
messageDeleted = "
"
messageDeleted += "notifications"
messageDeleted += "Some rules (${deletedRules.size()}) have been deleted in the Rule Manager App and removed from any containers. Please click the \"Done\" button to save after any modifications."
messageDeleted += "
"
return messageDeleted
}
// Else no deleted rules found
else {
if( logDebugEnable ) log.debug "No Deleted Rules Found..."
return false
}
}
// Endpoint used from javascript for quick saving options
def updateSettings() {
// Get user options from ajax request
userOpts = params.userOpts
// If user options are found
if( userOpts && userOpts != '' ) {
// Update app settings with user options
app.updateSetting( 'userArray', params.userOpts )
if( logDebugEnable ) log.debug "Updated Settings Successfully: " + params.userOpts
return [status: "success", message: "Options saved successfully." ]
}
// Else no user options were found
else {
// Return error code
if( logDebugEnable ) log.debug "Settings Error: Could Not Update"
return [status: "error", message: "Data passed from ajax is empty." ]
}
}
// Create access token
def createThisAccessToken() {
if( logDebugEnable ) log.debug "Checking Access Token..."
// Create token if not already found
if ( ! state.accessToken ) {
try {
def accessToken = createAccessToken()
if( accessToken ) {
state.accessToken = accessToken
if( logDebugEnable ) log.debug "Access Token Not Found; Created New Token: " + state.accessToken
}
}
catch(e) {
state.accessToken = null
if( logDebugEnable ) log.warn "Access Token Was Not Created... Token: " + state.accessToken
}
}
// Else token was already found
else {
if( logDebugEnable ) log.debug "Found Exisiting Access Token: " + state.accessToken
}
}
// Return a camel case string in capitals with spaces
def splitCamelCase(s) {
return s.replaceAll( String.format("%s|%s|%s", "(?<=[A-Z])(?=[A-Z][a-z])", "(?<=[^A-Z])(?=[A-Z])", "(?<=[A-Za-z])(?=[^A-Za-z])" ), " " ).capitalize();
}
// Helper function to define rule list
def createRulelistSubmap( ruleName, camelName, array ) {
attSubMap = [:]
attSubMap['name'] = array.data.name
attSubMap['disabled'] = array.data.disabled
attSubMap['paused'] = array.data.name.contains( '(Paused)' ) ? true : false
attSubMap['ruleType'] = camelName
// Add submap to rulemap
ruleMap[array.id] = attSubMap
}
// Populate rule list
def populateRuleList() {
// Define rule map; built from httpget call and rebuilding array
try {
httpGet([ uri: "http://127.0.0.1:8080/hub2/appsList" ]) { resp ->
if (resp.success) {
if( logDebugEnable ) log.debug "Getting Apps List..."
// Set array of allowed machine types
machineTypes = [:]
machineTypes['ruleMachine'] = 'Rule Machine'
machineTypes['roomLighting'] = 'Room Lighting'
machineTypes['buttonControllers'] = 'Button Controllers'
machineTypes['basicButtonControllers'] = 'Basic Button Controllers'
machineTypes['motionAndModeLightingApps'] = 'Motion and Mode Lighting Apps'
// Loop each app type
resp.data.apps.each {
// If this app is in our allowed list
if( machineTypes.containsValue( it.data.type ) ) {
// Define variables
ruleName = it.data.type
camelName = machineTypes.find{ it.value == ruleName }?.key
if( logDebugEnable ) log.debug "Adding ${ruleName} Rules..."
// Add to names map
ruleMapNames[camelName] = ruleName
// If this apps rules are in the grandchildren
if( ruleName == "Button Controllers" ) {
// Loop children
it.children.each{
// Loop children again
it.children.each{
// Create submap (define any needed variables)
createRulelistSubmap( ruleName, camelName, it )
}
}
}
// Else app rules are in the children
else {
// Loop children
it.children.each{
// Create submap (define any needed variables)
createRulelistSubmap( ruleName, camelName, it )
}
}
}
}
if( logDebugEnable ) log.debug "Finished Getting Apps List..."
}
}
}
catch (Exception e) { log.warn "Get Apps List Failed: ${e.message}" }
}
def installed() {
log.trace "Installed Rule Machine Manager Application"
updated()
}
def updated() {
// Create access token
if( ! state.accessToken) { createThisAccessToken() }
log.trace "Updated Rule Machine Manager Application"
if( logDebugEnable ) log.debug "Updated User Array: " + app.getSetting( 'userArray' )
if( logDebugEnable ) runIn( 900, logDebugOff )
}
def logDebugOff() {
log.warn "Debug Logging Disabled..."
app.updateSetting("logDebugEnable", [value: "false", type: "bool"])
}