/* * Rule Logging and State Checker * * Scans Rule Machine and Button Controller child apps and reports which rules appear to have * Actions, Events, and/or Triggers logging enabled, plus Disabled, Paused, and Private Boolean states. * * Designed initially by John Land, built by Claude AI with an assist by ChatGPT, then revised * to incorporate the excellent work of and feedback from hubitrep (the clickable cells are genius). * * Notes: * - Uses Hubitat local/internal JSON endpoints: * /hub2/appsList * /installedapp/statusJson/{appId} * /installedapp/configure/json/{appId} (logging toggle feature) * /installedapp/update/json (logging toggle feature) * /installedapp/disable (Disabled toggle; expects JSON body {id,disable} on * firmware 2.5.0.139+, form-urlencoded on earlier builds) * /installedapp/btn (Paused toggle) * /device/fullJson/{deviceId} (used by Device Status Checker companion app) * /device/update (used by Device Status Checker companion app) * /apps/api/{thisAppId}/setPB (Private Boolean toggle — this app's OAuth endpoint) * - Some of these endpoints are not a formal public API and could change in a * future Hubitat platform release. /installedapp/disable in particular changed its * expected payload format between firmware 2.5.0.136 and 2.5.0.139. * - Private Boolean toggling uses RMUtils.sendAction() with RM version "5.0". * Rules from earlier RM versions will display PB state but the toggle may not work. * * See README.md for version history. */ import hubitat.helper.RMUtils import groovy.transform.Field import groovy.transform.CompileStatic @Field static final String RM_BASE_URL = "http://127.0.0.1:8080" @Field static final String RM_VERSION = "5.0" @Field static final int SCAN_TIMEOUT_SECS = 360 // max seconds before scan is force-finalized @Field static final int LOGS_OFF_DELAY_SECS = 1800 // seconds before debug logging auto-disables // Transient scan state lives in @Field static to avoid database writes during a scan. // If the app class is reloaded during a scan (e.g. on code save or hub restart), // the scan is abandoned and can be run again. @Field static String currentScanId = null @Field static Long scanStartMs = 0L @Field static List scanRuleQueue = null @Field static Map scanPartialResults = null // keyed by ruleId String; holds both RM/BC and builtin rows definition( name: "Rule Logging and State Checker 1.70", namespace: "John Land", author: "John Land & AI", description: "Reports logging status and Disabled, Paused, and Private Boolean states for Hubitat rules.", category: "Utility", singleInstance: true, installOnOpen: true, oauth: true, iconUrl: '', iconX2Url: '', importUrl: "https://raw.githubusercontent.com/JohnFLand/Rule-Logging-and-State-Checker/refs/heads/main/Rule_Logging_Status_Checker.groovy" ) preferences { page(name: "mainPage") } // ── OAuth endpoint mapping ──────────────────────────────────────────────────── // Called by the browser JS click handler to toggle a rule's Private Boolean. // GET /apps/api/{thisAppId}/setPB?id={ruleId}&value=true|false&access_token={token} mappings { path("/setPB") { action: [GET: "handleSetPBEndpoint"] } path("/setpref") { action: [GET: "handleSetPrefEndpoint"] } path("/report") { action: [GET: "handleReportEndpoint"] } path("/RM-BC_Rules.csv") { action: [GET: "handleRmCsvEndpoint"] } path("/Built-In_Rules.csv") { action: [GET: "handleBuiltinCsvEndpoint"] } } // ============================================================ // Lifecycle // ============================================================ void installed() { syncAppInstanceLabel() checkOAuth() // auto-enable OAuth and create token on first install initialize() runIn(10, "findLoggingRules") } void updated() { syncAppInstanceLabel() boolean scanWasActive = (currentScanId != null) initialize() if (scanWasActive) { state.scanStatus = "Scan was cancelled because app settings were saved. Click Scan All Rules to run again." } else { reRenderReportIfCached() } } /* -------------------------------------------------------------------------- * App instance label helpers * -------------------------------------------------------------------------- */ private String getAppDisplayName() { String requested = settings?.vAppLabel?.toString()?.trim() if (requested) return requested // Legacy fallback for instances that already saved the old input "label" value. String legacyRequested = settings?.label?.toString()?.trim() if (legacyRequested) return legacyRequested String currentLabel = app?.label?.toString()?.trim() if (currentLabel) return currentLabel return app?.name?.toString() ?: "Rule Logging and State Checker" } private void syncAppInstanceLabel() { String requested = settings?.vAppLabel?.toString()?.trim() if (!requested) requested = settings?.label?.toString()?.trim() // legacy old-control value if (!requested) return String currentLabel = app?.label?.toString()?.trim() if (requested == currentLabel) return try { app.updateLabel(requested) if (debugEnable) log.debug "Rule Logging and State Checker: app label updated to '${requested}'" } catch (Exception e) { log.warn "Rule Logging and State Checker: app label update failed — ${e.message}" } } private void resetAppInstanceLabel() { String defaultName = app?.name?.toString() ?: "Rule Logging and State Checker" try { app.updateLabel(defaultName) app.updateSetting("vAppLabel", [value: defaultName, type: "text"]) log.info "Rule Logging and State Checker: app label reset to app name '${defaultName}'" } catch (Exception e) { log.warn "Rule Logging and State Checker: app label reset failed — ${e.message}" } } void initialize() { if (currentScanId != null) { log.warn "initialize: aborting in-progress scan (scanId: ${currentScanId}) — re-scan when ready" } currentScanId = null scanStartMs = 0L scanRuleQueue = null scanPartialResults = null unschedule("finalizeScanTimeout") if (debugEnable) { runIn(LOGS_OFF_DELAY_SECS, "logsOff") } } void logsOff() { app.updateSetting("debugEnable", [value: "false", type: "bool"]) } // Re-render the report HTML using rows cached in state.scanRowsJson. // Called from updated() so display-setting changes apply on Done without a rescan. void reRenderReportIfCached() { if (!state.scanRowsJson && !state.builtinRowsJson) return try { if (state.scanRowsJson) { List rows = new groovy.json.JsonSlurper().parseText(state.scanRowsJson) as List state.reportHtml = buildReportHtml(rows) log.info "RM/BC report re-rendered from cached scan data (${rows.size()} rules)" } if (state.builtinRowsJson) { List rows = new groovy.json.JsonSlurper().parseText(state.builtinRowsJson) as List state.builtinReportHtml = buildBuiltinReportHtml(rows) log.info "Built-in app report re-rendered from cached data (${rows.size()} apps)" } } catch (Exception e) { log.warn "reRenderReportIfCached: could not re-render — ${e.message}" } } // ── OAuth token management ──────────────────────────────────────────────────── // Self-enabling OAuth: on first install or page open, the app uses the hub's // internal loopback API to enable OAuth on its own app code, then creates the // access token — so the user never needs to visit Apps Code manually. // Step 1: look up this app's type ID from /hub2/userAppTypes. // The name must exactly match the name: field in definition(). private String getAppTypeId() { String typeId = null try { httpGet([uri: RM_BASE_URL, path: "/hub2/userAppTypes", timeout: 15]) { resp -> List apps = resp.data instanceof List ? (List) resp.data : [] Map match = apps.find { it.name == app.name } if (match) typeId = match.id?.toString() } } catch (Exception e) { log.debug "getAppTypeId: could not fetch user app types — ${e.message}" } return typeId } // Step 2: POST to /app/edit/update to enable OAuth on this app's code. // Requires the current internal version number from /app/ajax/code as a // concurrency guard — the POST is silently rejected without it. private boolean autoEnableOAuth() { String typeId = getAppTypeId() if (!typeId) { log.warn "autoEnableOAuth: could not determine app type ID — OAuth must be enabled manually in Apps Code" return false } String internalVer = null try { httpGet([uri: RM_BASE_URL, path: "/app/ajax/code", query: [id: typeId], timeout: 15]) { resp -> internalVer = resp.data?.version?.toString() } } catch (Exception e) { log.error "autoEnableOAuth: could not fetch app code version — ${e.message}" return false } if (!internalVer) { log.error "autoEnableOAuth: app code version was null — cannot proceed" return false } boolean success = false try { httpPost([ uri : RM_BASE_URL, path : "/app/edit/update", requestContentType : "application/x-www-form-urlencoded", body : [id: typeId, version: internalVer, oauthEnabled: "true", _action_update: "Update"], timeout : 20 ]) { resp -> success = true } if (success) log.info "autoEnableOAuth: OAuth successfully enabled on app code (typeId: ${typeId})" } catch (Exception e) { log.error "autoEnableOAuth: POST to /app/edit/update failed — ${e.message}" } return success } // Step 3: called from installed(), updated(), and mainPage(). // Returns true when a token is available. On first call, tries createAccessToken(); // if that throws (OAuth not yet enabled), calls autoEnableOAuth() then retries. boolean checkOAuth() { if (state.accessToken) return true try { createAccessToken() if (state.accessToken) { log.info "Rule Logging and State Checker: OAuth token created" return true } } catch (Exception e) { log.debug "checkOAuth: OAuth not yet enabled — attempting auto-enable via hub API..." if (autoEnableOAuth()) { try { createAccessToken() if (state.accessToken) { log.info "Rule Logging and State Checker: OAuth auto-enabled and token created successfully" return true } } catch (Exception e2) { log.error "checkOAuth: OAuth was enabled but token creation still failed — ${e2.message}" } } // Auto-enable failed — red warning shown in mainPage } return false } // ── Private Boolean OAuth endpoint ─────────────────────────────────────────── // Returns the render result so Hubitat's OAuth dispatcher receives a response body. // Declared as def (not void) for this reason — a void helper causes an empty HTTP response. def renderJson(Map m) { return render(contentType: "application/json", data: groovy.json.JsonOutput.toJson(m)) } def handleSetPBEndpoint() { String ruleId = params?.id String pbValue = params?.value if (!ruleId || pbValue == null) { log.warn "setPB endpoint called with missing params: id=${ruleId} value=${pbValue}" return renderJson([status: "error", message: "Missing id or value parameter"]) } if (!(ruleId ==~ /\d+/)) { log.warn "setPB endpoint: invalid rule id '${ruleId}'" return renderJson([status: "error", message: "Invalid rule id"]) } if (!(pbValue in ["true", "false"])) { log.warn "setPB endpoint: invalid value '${pbValue}'" return renderJson([status: "error", message: "Invalid value parameter — must be 'true' or 'false'"]) } String action = (pbValue == "true") ? "setRuleBooleanTrue" : "setRuleBooleanFalse" try { RMUtils.sendAction([ruleId as Long], action, app.label, RM_VERSION) if (debugEnable) log.debug "setPB: rule ${ruleId} → ${action}" return renderJson([status: "success"]) } catch (Exception e) { log.warn "setPB failed for rule ${ruleId} (${action}): ${e.message}" return renderJson([status: "error", message: e.message ?: "Unknown error"]) } } // ============================================================ // UI // ============================================================ def mainPage() { // Attempt to create the OAuth token on every page open — covers the case where the // user has just enabled OAuth in Apps Code and re-opened the app. checkOAuth() syncAppInstanceLabel() int pollInterval = currentScanId ? 5 : 0 String pageTitle = htmlEncode(getAppDisplayName()) dynamicPage(name: "mainPage", title: "${pageTitle}", install: true, uninstall: true, refreshInterval: pollInterval) { section("NOTE: Scanning may take a while, be patient!") { input "btnScan", "button", title: "Scan All Rules" if (state.lastScan) { paragraph "Last scan: ${state.lastScan} (Scan time: ${state.scanDuration ?: '00:00'})" } else { paragraph "No scan has been run yet." } if (state.scanStatus) { paragraph state.scanStatus } if (state.lastError) { paragraph "Last error: ${htmlEncode(state.lastError.toString())}" } } // ── Private Boolean toggle status ───────────────────────────────────── // Shown only when OAuth auto-enable failed — the normal (success) case // produces no UI noise since self-enabling OAuth is now the default. section("") { if (!state.accessToken) { paragraph "✗ PB toggle NOT active — automatic OAuth setup failed.
" + "Please enable it manually as a fallback:
" + "1. Go to Apps Code, open this app, click the three-dot menu, select OAuth, and press Enable OAuth in Smartapp.
" + "2. Return here and re-open the app — the token will be created automatically." } } boolean rmHidden = (settings.tableRmHidden != null) ? (settings.tableRmHidden as boolean) : false section("Rule Machine and Button Controller Rule State", hideable: true, hidden: rmHidden) { if (state.scannedCount != null) { paragraph "
" + "Rules scanned: ${state.scannedCount ?: 0}; " + "Any logging ON: ${state.anyLoggingOnCount ?: 0}; " + "Events: ${state.eventsOnCount ?: 0}; " + "Triggers: ${state.triggersOnCount ?: 0}; " + "Actions: ${state.actionsOnCount ?: 0}; " + "Private Bool TRUE: ${state.privateBoolOnCount ?: 0}; " + "Disabled: ${state.disabledCount ?: 0}; " + "Paused: ${state.pausedCount ?: 0}" + "

" } paragraph(state.reportHtml ?: "Click Scan All Rules to begin.") } section("") { input "tableRmHidden", "bool", title: "Hide Rule Machine/Button Controller Rule State table", defaultValue: false, submitOnChange: true } section("") { paragraph "" } // spacer between RM/BC and Built-in sections boolean biHidden = (settings.tableBiHidden != null) ? (settings.tableBiHidden as boolean) : false section("Built-in App Rule State", hideable: true, hidden: biHidden) { paragraph "for Hubitat built-in apps (Notifications, Basic Rules, Simple Automation Rules, Basic Button Controller, Room Lighting, Motion Lighting) that support a Logging setting" if (state.biScannedCount != null) { String unknownStyle = (state.biLogUnknownCount ?: 0) > 0 ? '' : " style='display:none'" String biStats = "
" + "Rules scanned: ${state.biScannedCount}; " + "Logging ON: ${state.biLogOnCount ?: 0}; " + "Logging OFF: ${state.biLogOffCount ?: 0}" + "" + "; Unknown: ${state.biLogUnknownCount ?: 0}" + "; " + "Disabled: ${state.biDisabledCount ?: 0}; " + "Paused: ${state.biPausedCount ?: 0}" + "

" paragraph biStats } if (state.builtinReportHtml) paragraph(state.builtinReportHtml) } section("") { input "tableBiHidden", "bool", title: "Hide Built-in App Rule State table", defaultValue: false, submitOnChange: true } section("") { paragraph "" } // spacer between Built-in and Notes sections section("Controls", hideable: true, hidden: true) { // ── App instance rename ─────────────────────────────────────── // Use a normal text preference and explicitly call app.updateLabel(). // The built-in Hubitat label control can show the edited value without // reliably updating the actual app instance label on this dynamic page. input "vAppLabel", "text", title: "App instance name", defaultValue: getAppDisplayName(), submitOnChange: true, width: 9 input "btnResetAppLabel", "button", title: "Reset to App Name", width: 3 // ── Report links — only available after a scan with a token ─── if (state.accessToken) { String base = "/apps/api/${app.id}/report?access_token=${state.accessToken}" if (state.scanRowsJson) { String rmCsvUrl = "/apps/api/${app.id}/RM-BC_Rules.csv?access_token=${state.accessToken}" paragraph "RM/BC Rule State Table  " + "" + "📄 Open Printable Report" + "  |  " + "⬇ Download CSV" } else { paragraph "Run Scan All Rules to enable RM/BC reports." } if (state.builtinRowsJson) { String biCsvUrl = "/apps/api/${app.id}/Built-In_Rules.csv?access_token=${state.accessToken}" paragraph "Built-in App Rule State Table  " + "" + "📄 Open Printable Report" + "  |  " + "⬇ Download CSV" } else { paragraph "Run Scan All Rules to enable Built-in App reports." } } else { paragraph "OAuth setup required before reports are available." } // ── Debug logging (last) ────────────────────────────────────── input "debugEnable", "bool", title: "Enable debug logging", defaultValue: false, submitOnChange: true } section("Notes", hideable: true, hidden: true) { paragraph """ Overview
This app scans Rule Machine (RM) and Button Controller (BC) rules and reports their logging status (Events, Triggers, Actions), Disabled and Paused states, and Private Boolean value in a first table. It also scans rules of supported Hubitat built-in apps (Notifications, Basic Rules, Simple Automation Rules, Basic Button Controller, Room Lighting, Motion Lighting) and reports their Logging setting and Disabled and Paused states in a second table. The two tables each have their own filter, sort, and hide controls. Button Controller rules show "" in the Events column because BC rules have no Events logging option. Rule types that expose only one broad logging toggle (rather than separate Events, Triggers, and Actions controls) appear in the Built-in App Logging table.
Scanning
Click Scan All Rules to start a scan. Both tables update automatically when the scan finishes — no manual refresh needed. Clicking Done and reopening the app re-renders both tables instantly from cached data, so display setting changes take effect without a rescan (but use data from the previous scan). If you install a new version, run a fresh scan once to regenerate the tables with any new columns or buttons.
Row filters
Each table has its own row filter buttons. In the RM/BC table, No logging ON is active by default, hiding rules where all logging is off; click it to show all rules. In the Built-in App Logging table, Logging OFF is active by default. Multiple row filters evaluate together — a row stays hidden if any active filter applies to it.
Name filter
Each table has a name filter field. Plain text performs a case-insensitive substring match, so partial searches do not require wildcards. Optional wildcard patterns are also supported: use * to match any sequence of characters and ? to match any single character, e.g. Contact*TU or *Motion*. Filtering combines with the row filter buttons — a row must pass both to be visible.
Table Visibility
A Hide toggle immediately below each table hides or shows that entire table. The setting takes effect immediately and persists across page opens. Each table's section heading is also clickable to collapse or expand it temporarily.
Row and column toggle buttons
Each table has its own hide-row and hide-column buttons above it. Clicking any button saves the preference automatically via the app's local OAuth endpoint — no "Done" press needed and the change persists across page opens.
Sorting
Click any column header to sort by that column; clicking the same header again reverses the sort direction. The default sort is by Rule name.
Clickable cells — RM/BC table
Click any Events, Triggers, Actions, Disabled, Paused, or Private Boolean cell to toggle that rule's setting in-place. The table cell updates immediately if successful. Cells where the field name could not be determined are not clickable.
Clickable cells — Built-in App Logging table
Click any Logging, Disabled, or Paused cell to toggle that setting in-place. After toggling Paused in the Built-in App Logging table, the rule is correctly paused immediately, but the (Paused) label on the Automations page may require a browser page refresh to appear.
Note on in-place toggles and cached data
In-place cell toggles update the live table and summary counts immediately. However, printable HTML reports and CSV exports are built from the data captured during the last scan — they will not reflect in-place changes until you run Scan All Rules again.
Private Boolean (RM/BC table)
Click any Private Bool cell to toggle a rule's Private Boolean between TRUE and FALSE. TRUE is displayed in bold blue; FALSE in grey. Cells showing "" mean the PB state could not be read and are not clickable. The toggle calls RMUtils.sendAction() via this app's local OAuth endpoint, targeting RM version ${RM_VERSION} rules. OAuth is enabled automatically on first install — no manual setup required. If the PB toggle ever shows inactive, re-open the app to retry; if it still fails, enable OAuth manually via the three-dot menu in Apps Code, then re-open. The token persists across hub reboots and app updates.
Last Run column
Shows the date and time of the most recent trigger event for each rule, normalised to 24-hour HH:mm format regardless of how individual rules store the time. A blank cell means the rule has never been triggered since it was last installed. Last Run reflects when the rule was triggered, not necessarily when its actions completed.
Summary counts
Shown as part of each table's heading area. Counts are computed from the most recent scan. Toggling any cell in-place updates both the cell and the relevant summary count immediately — Events, Triggers, Actions, Private Boolean, Disabled, and Paused all reflect in-place changes without a rescan. Run Scan All Rules again to recompute all counts from a fresh scan.
Controls section
The collapsible Controls section (above Notes) provides four functions:
App instance name — type a custom name for this app instance; the name appears in the Hubitat Apps list and logs. Reset to App Name restores the current app code name/version.
Printable HTML reports — opens a clean, print-optimised version of each table in a new browser tab. All rows are shown regardless of current filter state. Use the browser's Print or Save as PDF function from that tab. Reports reflect the last scan; run Scan All Rules first to include recent in-place changes.
CSV export — downloads the table data as a CSV file (RM-BC_Rules.csv or Built-In_Rules.csv) for use in a spreadsheet. Exports reflect the last scan; run Scan All Rules first to include recent in-place changes.
Enable debug logging — turns on verbose logging to the Hubitat log for 30 minutes, then disables itself automatically.
WARNING
This app uses Hubitat local/internal JSON endpoints. Those endpoints and Rule Machine / Button Controller / built-in app internal setting names are not a formal public API, so the detection logic may need to be adjusted if Hubitat changes the JSON format in a future platform update. For example, the /installedapp/disable endpoint changed its expected payload format (form-urlencoded → JSON body) between firmware 2.5.0.136 and 2.5.0.139.
""" } } } def appButtonHandler(String btn) { switch (btn) { case "btnScan": findLoggingRules() break case "btnResetAppLabel": resetAppInstanceLabel() break default: log.warn "Unknown button: ${btn}" break } } // ============================================================ // Scanning — async sequential chain // ============================================================ void findLoggingRules() { state.lastError = null state.scanStatus = "Scan in progress…" state.reportHtml = null state.builtinReportHtml = null state.scanRowsJson = null // clear cache so updated() won't re-render stale data mid-scan state.builtinRowsJson = null // Cancel any prior scheduled timeout before setting a new one so a stale timer // from a previous scan can never fire against the current one. unschedule("finalizeScanTimeout") runIn(SCAN_TIMEOUT_SECS, "finalizeScanTimeout") List ruleApps = getRuleMachineRuleApps() List builtinApps = getBuiltinAppInstances() List combined = ruleApps + builtinApps if (combined.isEmpty()) { unschedule("finalizeScanTimeout") state.scannedCount = 0 state.actionsOnCount = 0 state.eventsOnCount = 0 state.triggersOnCount = 0 state.anyLoggingOnCount = 0 state.privateBoolOnCount = 0 state.disabledCount = 0 state.pausedCount = 0 state.lastScan = new Date().format("yyyy-MM-dd HH:mm:ss", location.timeZone) state.scanDuration = "00:00" state.biScannedCount = 0 state.biLogOnCount = 0 state.biLogOffCount = 0 state.biLogUnknownCount = 0 state.biDisabledCount = 0 state.biPausedCount = 0 state.builtinReportHtml = "" state.reportHtml = "

No Rule Machine, Button Controller, or supported built-in apps found.

" state.scanStatus = null return } Long nowMs = now() String scanId = nowMs.toString() String scanStartTime = new Date().format("yyyy-MM-dd HH:mm:ss", location.timeZone) List queue = combined.collect { Map r -> [id : r.id as String, name : r.name as String, appType : (r.appType ?: "RM") as String, appClass : (r.appClass ?: "rm") as String, disabled : r.disabled as Boolean, paused : r.paused as Boolean] } state.scanStatus = "Scan started: ${scanStartTime} — scanning ${queue.size()} apps…" // Assign all transient scan state to @Field statics — zero DB writes during the scan scanRuleQueue = queue scanPartialResults = [:] currentScanId = scanId scanStartMs = nowMs log.info "Scan started — ${queue.size()} rules (scanId: ${scanId})" Map first = queue[0] asynchttpGet("handleStatusResponse", [uri: RM_BASE_URL, path: "/installedapp/statusJson/${first.id}", timeout: 60], [scanId : scanId, ruleId : first.id, ruleName : first.name, appType : first.appType, appClass : (first.appClass ?: "rm"), disabled : first.disabled, paused : first.paused, nextIdx : 1, totalRules : queue.size()] ) } void handleStatusResponse(resp, data) { String scanId = data.scanId as String if (currentScanId != scanId) return // stale callback from a cancelled scan String ruleId = data.ruleId as String try { Map status = [:] try { int httpStatus = resp.getStatus() as int if (httpStatus == 200) { Object raw = resp.getData() if (raw instanceof Map) { status = raw as Map } else if (raw != null) { status = new groovy.json.JsonSlurper().parseText(raw.toString()) as Map ?: [:] } } else { log.warn "HTTP ${httpStatus} for rule ${ruleId} (${data.ruleName})" } } catch (Exception e) { log.warn "Error parsing statusJson for rule ${ruleId}: ${e.message}" } if (scanPartialResults == null) scanPartialResults = [:] if ((data.appClass ?: "rm") == "builtin") { // Built-in app (Notifications, Basic Rules, Room Lighting, etc.) Boolean loggingOn = extractBuiltinLogging(status) if (debugEnable) log.debug "Builtin: ${data.ruleName} (${ruleId}, ${data.appType}) logging=${loggingOn}" scanPartialResults[ruleId] = [ id : ruleId, name : data.ruleName, appType : data.appType, appClass : "builtin", disabled : data.disabled, paused : data.paused, logging : loggingOn, lastRun : extractLastRun(status) ] } else { // RM / BC rule Map logging = detectRuleLogging(status) Boolean actionsOn = logging.actionsOn as Boolean Boolean eventsOn = logging.eventsOn as Boolean Boolean triggersOn = logging.triggersOn as Boolean // null = field absent (render as —); true/false = known state Boolean privateBool = extractPrivateBool(status) if (debugEnable && (actionsOn || eventsOn || triggersOn)) { log.debug "Logging ON: ${data.ruleName} (${ruleId}, ${data.appType}) Actions=${actionsOn}, Events=${eventsOn}, Triggers=${triggersOn}, PrivateBool=${privateBool}" } else if (debugEnable) { log.debug "Logging off: ${data.ruleName} (${ruleId}) PrivateBool=${privateBool}" } scanPartialResults[ruleId] = [ id : ruleId, name : data.ruleName, appType : data.appType, appClass : "rm", disabled : data.disabled, paused : data.paused, actionsOn : actionsOn, eventsOn : eventsOn, triggersOn : triggersOn, actionsField : logging.actionsField, eventsField : logging.eventsField, triggersField : logging.triggersField, allLoggingField : logging.allLoggingField, lastRun : extractLastRun(status), privateBool : privateBool // may be null ] } } catch (Exception e) { log.warn "handleStatusResponse error for rule ${ruleId} (${data.ruleName}): ${e.message}" if (scanPartialResults == null) scanPartialResults = [:] String errClass = (data.appClass ?: "rm") as String if (errClass == "builtin") { scanPartialResults[ruleId] = [ id: ruleId, name: data.ruleName as String, appType: (data.appType ?: "") as String, appClass: "builtin", disabled: data.disabled as Boolean, paused: data.paused as Boolean, logging: null, lastRun: "" ] } else { scanPartialResults[ruleId] = [ id: ruleId, name: data.ruleName as String, appType: (data.appType ?: "RM") as String, appClass: "rm", disabled: data.disabled as Boolean, paused: data.paused as Boolean, actionsOn: false, eventsOn: false, triggersOn: false, actionsField: null, eventsField: null, triggersField: null, allLoggingField: null, lastRun: "", privateBool: null ] } } finally { if (currentScanId != scanId) return // scan was cancelled while we were processing int nextIdx = (data.nextIdx ?: 0) as int int totalRules = (data.totalRules ?: 0) as int if (debugEnable) log.debug "Completed ${nextIdx}/${totalRules}: ${data.ruleName} (${ruleId})" if (nextIdx < totalRules) { Map nextRule = scanRuleQueue[nextIdx] asynchttpGet("handleStatusResponse", [uri: RM_BASE_URL, path: "/installedapp/statusJson/${nextRule.id}", timeout: 60], [scanId : currentScanId, ruleId : nextRule.id as String, ruleName : nextRule.name as String, appType : (nextRule.appType ?: "RM") as String, appClass : (nextRule.appClass ?: "rm") as String, disabled : nextRule.disabled as Boolean, paused : nextRule.paused as Boolean, nextIdx : nextIdx + 1, totalRules : totalRules] ) } else { finalizeScan() } } } void finalizeScan() { unschedule("finalizeScanTimeout") List asyncRules = scanRuleQueue ?: [] Map partialResults = scanPartialResults ?: [:] List allRows = asyncRules.collect { Map rule -> Map row = partialResults[rule.id as String] as Map if (row) return row log.warn "No response for ${rule.id} (${rule.name}) — reporting as no logging" String ac = (rule.appClass ?: "rm") as String if (ac == "builtin") { return [id: rule.id as String, name: rule.name as String, appType: (rule.appType ?: "") as String, appClass: "builtin", disabled: rule.disabled as Boolean, paused: rule.paused as Boolean, logging: null, lastRun: ""] } return [id: rule.id as String, name: rule.name as String, appType: (rule.appType ?: "RM") as String, appClass: "rm", disabled: rule.disabled as Boolean, paused: rule.paused as Boolean, actionsOn: false, eventsOn: false, triggersOn: false, actionsField: null, eventsField: null, triggersField: null, allLoggingField: null, lastRun: "", privateBool: null] } // Split into RM/BC rows and built-in app rows List rmRows = allRows.findAll { (it.appClass ?: "rm") != "builtin" } List builtinRows = allRows.findAll { (it.appClass ?: "rm") == "builtin" } Integer actionsOnCount = rmRows.count { it.actionsOn } as Integer Integer eventsOnCount = rmRows.count { it.eventsOn } as Integer Integer triggersOnCount = rmRows.count { it.triggersOn } as Integer Integer anyLoggingOnCount = rmRows.count { (it.actionsOn || it.eventsOn || it.triggersOn) } as Integer Integer privateBoolOnCount = rmRows.count { it.privateBool == true } as Integer Integer disabledCount = rmRows.count { it.disabled == true } as Integer Integer pausedCount = rmRows.count { it.paused == true } as Integer state.scannedCount = rmRows.size() state.actionsOnCount = actionsOnCount state.eventsOnCount = eventsOnCount state.triggersOnCount = triggersOnCount state.anyLoggingOnCount = anyLoggingOnCount state.privateBoolOnCount = privateBoolOnCount state.disabledCount = disabledCount state.pausedCount = pausedCount state.lastScan = new Date().format("yyyy-MM-dd HH:mm:ss", location.timeZone) state.scanDuration = formatScanDuration((now() as Long) - (scanStartMs ?: now() as Long)) // Cache row data for both tables so updated() can re-render on settings change without rescan. try { state.scanRowsJson = groovy.json.JsonOutput.toJson(rmRows) state.builtinRowsJson = groovy.json.JsonOutput.toJson(builtinRows) } catch (Exception e) { log.warn "finalizeScan: could not cache scan rows — ${e.message}" state.scanRowsJson = null state.builtinRowsJson = null } state.biScannedCount = builtinRows.size() state.biLogOnCount = builtinRows.count { it.logging == true } as Integer state.biLogOffCount = builtinRows.count { it.logging == false } as Integer state.biLogUnknownCount = builtinRows.count { it.logging == null } as Integer state.biDisabledCount = builtinRows.count { it.disabled == true } as Integer state.biPausedCount = builtinRows.count { it.paused == true } as Integer state.reportHtml = buildReportHtml(rmRows) state.builtinReportHtml = buildBuiltinReportHtml(builtinRows) state.scanStatus = null // Release @Field memory and mark scan complete — cleared AFTER reportHtml is written // so the page keeps auto-refreshing until the report is ready. currentScanId = null scanPartialResults = null scanRuleQueue = null log.info "Scan complete in ${state.scanDuration}: ${rmRows.size()} RM/BC rules (any logging ON: ${anyLoggingOnCount}, Events: ${eventsOnCount}, Triggers: ${triggersOnCount}, Actions: ${actionsOnCount}, PB TRUE: ${privateBoolOnCount}); ${builtinRows.size()} built-in apps" } void finalizeScanTimeout() { if (currentScanId != null) { int total = scanRuleQueue?.size() ?: 0 log.warn "Scan timeout: finalizing with partial results (${total} rules in queue)" finalizeScan() } } // ============================================================ // Rule discovery // ============================================================ List getRuleMachineRuleApps() { List rules = [] Set seenIds = [] as Set Map params = [ uri : RM_BASE_URL, path : "/hub2/appsList", contentType : "application/json" ] try { httpGet(params) { resp -> resp.data?.apps?.each { parentApp -> def pd = parentApp?.data String parentType = pd?.type?.toString() ?: "" String parentName = pd?.name?.toString() ?: "" String parentLabel = pd?.label?.toString() ?: "" String appType = getSupportedAutomationAppType(parentType, parentName, parentLabel) if (appType) { parentApp?.children?.each { child -> collectRmLeafRules(child, appType, rules, seenIds, 0) } } } } } catch (Exception e) { state.lastError = "Unable to read /hub2/appsList. This may be temporary; try Scan again. Error: ${e.message}" log.warn state.lastError } return rules.sort { it.name?.toLowerCase() ?: "" } } // Recursively collect leaf nodes from the RM/BC app tree. // Mirrors collectBuiltinLeafRules() but preserves the BC type-detection logic // needed to correctly label Button Controller rules within an RM parent. private void collectRmLeafRules(Object node, String parentAppType, List rules, Set seenIds, int depth) { if (depth > 6) return List children = (node?.children ?: []) as List if (children.isEmpty()) { def d = node?.data if (d?.id && d?.name) { String id = d.id.toString() if (!seenIds.contains(id)) { String childType = d?.type?.toString() ?: "" String childAppName = d?.appName?.toString() ?: "" String childDetectedType = getSupportedAutomationAppType(childType, childAppName) String finalAppType = (parentAppType == "BC" || childDetectedType == "BC") ? "BC" : (childDetectedType ?: parentAppType) seenIds << id String ruleName = d.name.toString() rules << [ id : id, name : ruleName, appType : finalAppType, disabled : asBooleanLoose(d.disabled), paused : ruleName.contains("(Paused)") ] } } } else { children.each { child -> collectRmLeafRules(child, parentAppType, rules, seenIds, depth + 1) } } } String getSupportedAutomationAppType(String type, String name, String label = "") { String combined = [type, name, label].findAll { it }.join(" ").toLowerCase() if (!combined) return null /* * Basic Button Controller is intentionally excluded. It exposes only one broad * logging toggle rather than separate controls for Actions, Events, and/or Triggers. */ if (combined.contains("basic button controller") || combined.contains("basicbuttoncontroller")) { return null } if (combined.contains("button controller") || combined.contains("buttoncontroller")) { return "BC" } /* * Keep Rule Machine matching specific to avoid false positives from other apps * whose names happen to contain the word "rule". */ if (combined.contains("rule machine") || combined.contains("rulemachine")) { return "RM" } return null } // ============================================================ // Built-in app discovery // ============================================================ // Detects top-level Hubitat built-in apps (Notifications, Basic Rules, Simple Automation Rules, // Basic Button Controller, Room Lighting, Motion Lighting) that support a boolean "logging" // setting. Unlike RM/BC rules these are parent apps, not children of Rule Machine, so they // appear at the top level in /hub2/appsList. List getBuiltinAppInstances() { List apps = [] Set seenIds = [] as Set try { httpGet([uri: RM_BASE_URL, path: "/hub2/appsList", contentType: "application/json"]) { resp -> resp.data?.apps?.each { parentApp -> def pd = parentApp?.data if (!pd) return String type = pd?.type?.toString() ?: "" String name = pd?.name?.toString() ?: "" String label = pd?.label?.toString() ?: "" String appType = getBuiltinAppType(type, name, label) // Found a supported built-in parent — recursively collect leaf nodes. // Leaf nodes (no children) are the actual rules; intermediate nodes // (with children) are containers or groups to descend through. // This handles variable nesting depth across different app types — // e.g. Basic Button Controller has more tiers than Notifications. if (appType) { parentApp?.children?.each { child -> collectBuiltinLeafRules(child, appType, apps, seenIds, 0) } } } } } catch (Exception e) { log.warn "getBuiltinAppInstances: could not read /hub2/appsList — ${e.message}" } return apps.sort { it.name?.toLowerCase() ?: "" } } // Recursively descend the app tree, collecting only leaf nodes (nodes with no children) // as the actual rules. Intermediate container/group nodes are skipped. // depth guard prevents runaway recursion on unexpectedly deep structures. private void collectBuiltinLeafRules(Object node, String appType, List apps, Set seenIds, int depth) { if (depth > 6) return List children = (node?.children ?: []) as List if (children.isEmpty()) { // Leaf — this is an actual rule def d = node?.data if (d?.id && d?.name) { String id = d.id.toString() if (!seenIds.contains(id)) { seenIds << id String ruleName = d.name.toString() apps << [ id : id, name : ruleName, appType : appType, appClass : "builtin", disabled : asBooleanLoose(d.disabled), paused : ruleName.contains("(Paused)") ] } } } else { // Intermediate container — recurse into children children.each { child -> collectBuiltinLeafRules(child, appType, apps, seenIds, depth + 1) } } } // Returns a display-friendly app type name for supported built-in apps, or null if not recognised. String getBuiltinAppType(String type, String name, String label = "") { String combined = [type, name, label].findAll { it }.join(" ").toLowerCase() if (!combined) return null // Avoid false positives — check more specific strings first if (combined.contains("room lighting") || combined.contains("roomlighting")) return "Room Lighting" // "Motion and Mode Lighting Apps" is the umbrella container in the hub's app list; // "motion lighting" catches any directly-named Motion Lighting instances. // Both map to the same appType since we only want Motion Lighting children. if (combined.contains("motion and mode lighting") || combined.contains("motionandmodelighting") || combined.contains("motion lighting") || combined.contains("motionlighting")) return "Motion Lighting" if (combined.contains("simple automation") || combined.contains("simpleautomation")) return "Simple Automation Rules" if (combined.contains("basic button controller") || combined.contains("basicbuttoncontroller")) return "Basic Button Controller" if ((combined.contains("basic rule") || combined.contains("basicroomrule")) && !combined.contains("button")) return "Basic Rule" if (combined.contains("notification") && !combined.contains("button") && !combined.contains("rule")) return "Notifications" return null } // ── Preference persistence endpoint ───────────────────────────────────────── // Called by toggle-bar buttons via fetch() to persist their state without a // page reload. Values are stored in state.userPrefs and read via getPref(). def handleSetPrefEndpoint() { if (!state.accessToken) { return renderJson([status: "error", message: "OAuth not active"]) } String key = params?.key?.toString() String value = params?.value?.toString() if (!key) { return renderJson([status: "error", message: "missing key"]) } Map prefs = (state.userPrefs ?: [:]) as Map prefs[key] = value state.userPrefs = prefs return renderJson([status: "success"]) } // Read a toggle-bar preference from state.userPrefs; returns defaultVal if not set. boolean getPref(String key, boolean defaultVal = false) { Map prefs = (state.userPrefs ?: [:]) as Map if (prefs.containsKey(key)) return prefs[key]?.toString() == "true" return defaultVal } // ============================================================ // Report endpoint — printable HTML and CSV exports // ============================================================ // GET /apps/api/{id}/report?access_token={token}&table={rm|builtin} // Opens a self-contained printable HTML page using cached scan rows. // CSV downloads use the dedicated /RM-BC_Rules.csv and /Built-In_Rules.csv endpoints. def handleReportEndpoint() { if (!state.accessToken) { render contentType: "text/plain", data: "OAuth not active — re-open the app to retry." return } String table = (params?.table ?: "rm").toString().toLowerCase() String html = (table == "builtin") ? buildBuiltinPrintHtml() : buildRmPrintHtml() render contentType: "text/html; charset=UTF-8", data: html } // Dedicated CSV download endpoints — named paths give browsers the correct filename. def handleRmCsvEndpoint() { if (!state.accessToken) { render contentType: "text/plain", data: "OAuth not active."; return } render contentType: "text/csv; charset=UTF-8", data: buildRmCsv() } def handleBuiltinCsvEndpoint() { if (!state.accessToken) { render contentType: "text/plain", data: "OAuth not active."; return } render contentType: "text/csv; charset=UTF-8", data: buildBuiltinCsv() } // ── Shared print HTML shell ─────────────────────────────────────────────────── private String printHtmlShell(String title, String subtitle, String tableHtml) { return """ ${htmlEncode(title)}

${htmlEncode(title)}

${htmlEncode(subtitle)}

${tableHtml} """ } // ── RM/BC printable HTML ────────────────────────────────────────────────────── @CompileStatic private String onOff(Boolean v) { if (v == null) return "—" return v ? "ON" : "OFF" } @CompileStatic private String yesNo(Boolean v) { if (v == null) return "—" return v ? "Yes" : "No" } @CompileStatic private String pbFmt(Object v) { if (v == null) return "—" return (v as Boolean) ? "TRUE" : "FALSE" } @CompileStatic private String escapeCsv(Object v) { if (v == null) return "" String s = v.toString().replace('"', '""') return (s.contains(",") || s.contains('"') || s.contains("\n")) ? "\"${s}\"" : s } String buildRmPrintHtml() { List rows = [] try { rows = new groovy.json.JsonSlurper().parseText(state.scanRowsJson ?: "[]") as List } catch (e) {} rows = rows.sort { it.name?.toString()?.toLowerCase() ?: "" } StringBuilder sb = new StringBuilder() sb << "" ["Rule ID","Rule","App Type","Disabled","Paused","Events","Triggers","Actions","Private Bool","Last Run"].each { sb << "" } sb << "" rows.each { Map r -> sb << "" sb << "" sb << "" sb << "" sb << "" sb << "" String evCell = (r.appType?.toString() == "BC") ? "—" : onOff(r.eventsOn as Boolean) sb << "" sb << "" sb << "" sb << "" sb << "" sb << "" } sb << "
${it}
${htmlEncode(r.id)}${htmlEncode(r.name)}${htmlEncode(r.appType ?: "")}${yesNo(r.disabled as Boolean)}${yesNo(r.paused as Boolean)}${evCell}${onOff(r.triggersOn as Boolean)}${onOff(r.actionsOn as Boolean)}${pbFmt(r.privateBool)}${htmlEncode(r.lastRun ?: "")}
" String subtitle = "Last scan: ${state.lastScan ?: "never"} — ${rows.size()} rules" return printHtmlShell("Rule Machine and Button Controller Logging and State", subtitle, sb.toString()) } // ── Built-in printable HTML ─────────────────────────────────────────────────── String buildBuiltinPrintHtml() { List rows = [] try { rows = new groovy.json.JsonSlurper().parseText(state.builtinRowsJson ?: "[]") as List } catch (e) {} rows = rows.sort { it.name?.toString()?.toLowerCase() ?: "" } StringBuilder sb = new StringBuilder() sb << "" ["Rule ID","Rule","App Type","Disabled","Paused","Logging","Last Run"].each { sb << "" } sb << "" rows.each { Map r -> Boolean logVal = r.logging == null ? null : (r.logging as Boolean) String logFmt = (logVal == null) ? "—" : logVal ? "ON" : "OFF" sb << "" sb << "" sb << "" sb << "" sb << "" sb << "" sb << "" sb << "" sb << "" } sb << "
${it}
${htmlEncode(r.id)}${htmlEncode(r.name)}${htmlEncode(r.appType ?: "")}${yesNo(r.disabled as Boolean)}${yesNo(r.paused as Boolean)}${logFmt}${htmlEncode(r.lastRun ?: "")}
" String subtitle = "Last scan: ${state.lastScan ?: "never"} — ${rows.size()} apps" return printHtmlShell("Built-in App Logging", subtitle, sb.toString()) } // ── RM/BC CSV ───────────────────────────────────────────────────────────────── String buildRmCsv() { List rows = [] try { rows = new groovy.json.JsonSlurper().parseText(state.scanRowsJson ?: "[]") as List } catch (e) {} rows = rows.sort { it.name?.toString()?.toLowerCase() ?: "" } StringBuilder sb = new StringBuilder() sb << "Rule ID,Rule,App Type,Disabled,Paused,Events,Triggers,Actions,Private Bool,Last Run\n" rows.each { Map r -> String ev = (r.appType?.toString() == "BC") ? "—" : (r.eventsOn == null ? "—" : (r.eventsOn as Boolean) ? "ON" : "OFF") sb << "${escapeCsv(r.id)},${escapeCsv(r.name)},${escapeCsv(r.appType)}" sb << ",${r.disabled ? "Yes" : "No"},${r.paused ? "Yes" : "No"}" sb << ",${ev}" sb << ",${r.triggersOn == null ? "—" : (r.triggersOn as Boolean) ? "ON" : "OFF"}" sb << ",${r.actionsOn == null ? "—" : (r.actionsOn as Boolean) ? "ON" : "OFF"}" sb << ",${r.privateBool == null ? "—" : (r.privateBool as Boolean) ? "TRUE" : "FALSE"}" sb << ",${escapeCsv(r.lastRun)}\n" } return sb.toString() } // ── Built-in CSV ────────────────────────────────────────────────────────────── String buildBuiltinCsv() { List rows = [] try { rows = new groovy.json.JsonSlurper().parseText(state.builtinRowsJson ?: "[]") as List } catch (e) {} rows = rows.sort { it.name?.toString()?.toLowerCase() ?: "" } StringBuilder sb = new StringBuilder() sb << "Rule ID,Rule,App Type,Disabled,Paused,Logging,Last Run\n" rows.each { Map r -> Boolean logVal = r.logging == null ? null : (r.logging as Boolean) String logStr = logVal == null ? "—" : logVal ? "ON" : "OFF" sb << "${escapeCsv(r.id)},${escapeCsv(r.name)},${escapeCsv(r.appType)}" sb << ",${r.disabled ? "Yes" : "No"},${r.paused ? "Yes" : "No"}" sb << ",${logStr},${escapeCsv(r.lastRun)}\n" } return sb.toString() } // ============================================================ // Logging detection // ============================================================ Map detectRuleLogging(Map status) { /* * We check multiple places because Hubitat/RM internals can vary by version. * Common field names are expected to be things like: * logActions = true * logEvents = true * logTriggers = true * * This also tries to catch variants such as: * actionsLogging = true * eventsLogging = true * triggersLogging = true * logging = ["Actions", "Events", "Triggers"] * logAll = true */ List candidates = [] collectCandidatesFromObject("appSettings", status?.appSettings, candidates) collectCandidatesFromObject("settings", status?.settings, candidates) Map allResult = detectAllLogging(candidates) Boolean allLoggingOn = allResult.detected as Boolean Map actionsResult = detectSpecificLogging(candidates, "actions", ["action", "actions"]) Map eventsResult = detectSpecificLogging(candidates, "events", ["event", "events"]) Map triggersResult = detectSpecificLogging(candidates, "triggers", ["trigger", "triggers"]) Map result = [ actionsOn : allLoggingOn || (actionsResult.matched as Boolean), eventsOn : allLoggingOn || (eventsResult.matched as Boolean), triggersOn : allLoggingOn || (triggersResult.matched as Boolean), actionsField : actionsResult.fieldName as String, eventsField : eventsResult.fieldName as String, triggersField : triggersResult.fieldName as String, allLoggingField : allResult.fieldName as String ] // When no field names were resolved, log candidates (only when debug logging is enabled) if (debugEnable && !result.actionsField && !result.eventsField && !result.triggersField && !result.allLoggingField) { List logCandidates = candidates .findAll { String k = it.name?.toString()?.toLowerCase() ?: ""; k.contains("log") || k.contains("debug") } .collect { "${it.source}/${it.name}=${it.value}" } if (logCandidates) { log.debug "detectRuleLogging: no logging fields found — log-related candidates: ${logCandidates}" } else { log.debug "detectRuleLogging: no logging fields found — all candidates: ${candidates.collect { "${it.source}/${it.name}=${it.value}" }}" } } return result } Map detectAllLogging(List candidates) { Set exactMatches = ["alllogging", "logall", "logsall"] as Set String disabledFieldName = null for (Map c : candidates) { String key = c.name?.toString() ?: "" String k = key.toLowerCase() if (k in exactMatches || (k.contains("log") && k.contains("all"))) { String fieldName = key.contains(".") ? key.tokenize(".").last() : key if (valueLooksEnabled(c.value)) return [detected: true, fieldName: fieldName] if (!disabledFieldName) disabledFieldName = fieldName } } return [detected: false, fieldName: disabledFieldName] } Map detectSpecificLogging(List candidates, String canonicalName, List needles) { Set exactKeys = ["log${canonicalName}", "${canonicalName}log", "${canonicalName}logging", "logging${canonicalName}"] as Set Set generalKeys = ["logging", "logs", "log", "logoptions", "loggingoptions", "logsettings", "logsetting"] as Set String disabledFieldName = null for (Map c : candidates) { String key = c.name?.toString() ?: "" String k = key.toLowerCase() String v = c.value?.toString()?.toLowerCase() ?: "" Boolean keyNamesThisLogging = (k in exactKeys) || needles.any { String n -> (k.contains("log") && k.contains(n)) || (k.contains(n) && k.contains("logging")) } if (keyNamesThisLogging) { String fieldName = key.contains(".") ? key.tokenize(".").last() : key if (valueLooksEnabled(c.value)) return [matched: true, fieldName: fieldName] if (!disabledFieldName) disabledFieldName = fieldName } if (k in generalKeys) { String fieldName = key.contains(".") ? key.tokenize(".").last() : key if (!disabledFieldName) disabledFieldName = fieldName if (needles.any { String n -> v.contains(n) } && !valueLooksDisabled(c.value)) { return [matched: true, fieldName: fieldName] } } } return [matched: false, fieldName: disabledFieldName] } void collectCandidatesFromObject(String source, Object obj, List candidates) { collectCandidatesFromObject(source, obj, candidates, "", 0) } void collectCandidatesFromObject(String source, Object obj, List candidates, String prefix, int depth) { // Depth cap: RM/BC statusJson nesting is typically ≤ 3 levels; 4 is a safe ceiling that prevents // runaway recursion on unexpectedly deep structures. if (obj == null || depth > 4) return if (obj instanceof Map) { obj.each { k, v -> String name = prefix ? "${prefix}.${k?.toString()}" : k?.toString() candidates << [source: source, name: name, value: v] if (v instanceof Map || v instanceof Collection) { collectCandidatesFromObject(source, v, candidates, name, depth + 1) } } return } if (obj instanceof Collection) { Integer idx = 0 obj.each { row -> String name = prefix ? "${prefix}[${idx}]" : "[${idx}]" if (row instanceof Map) { Object rowName = row.name ?: row.key ?: row.id ?: row.label ?: name candidates << [source: source, name: rowName?.toString(), value: row.value] collectCandidatesFromObject(source, row, candidates, name, depth + 1) } else { candidates << [source: source, name: name, value: row] } idx++ } } } @CompileStatic Boolean valueLooksEnabled(Object value) { if (value == null) return false if (value instanceof Boolean) return (Boolean) value if (value instanceof Collection) return !(value as Collection).isEmpty() String v = value.toString().trim().toLowerCase() return v in ["true", "on", "yes", "enabled", "enable", "1"] } @CompileStatic Boolean valueLooksDisabled(Object value) { if (value == null) return true if (value instanceof Boolean) return !(Boolean) value if (value instanceof Collection) return (value as Collection).isEmpty() String v = value.toString().trim().toLowerCase() return v in ["false", "off", "no", "disabled", "disable", "0", "null", ""] } @CompileStatic Boolean asBooleanLoose(Object value) { if (value == null) return false if (value instanceof Boolean) return value return value.toString().equalsIgnoreCase("true") } // ============================================================ // Private Boolean extraction // ============================================================ // Returns true/false when the "private" field is present in appState, // or null when the field is absent (status unreadable or rule returned no appState). // Callers should treat null as unknown, not as false. Boolean extractPrivateBool(Map status) { for (Map item : (status?.appState ?: [])) { if (item?.name?.toString() == "private") { return asBooleanLoose(item?.value) } } return null } // Reads the "logging" boolean setting from built-in apps (Notifications, Basic Rules, // Simple Automation Rules, Basic Button Controller, Room Lighting, Motion Lighting). // These apps use a simple boolean field named "logging" in their settings. // Returns true/false when the field is found, null when absent or status unreadable. Boolean extractBuiltinLogging(Map status) { // Iterate appSettings first then settings — two heterogeneous sources, same field shape for (Object source : [status?.appSettings, status?.settings]) { if (source instanceof Map) { if (source.containsKey("logging")) { return asBooleanLoose(source.logging) } } else if (source instanceof Collection) { for (Map item : source) { if (item?.name?.toString() == "logging") { return asBooleanLoose(item?.value) } } } } return null } // ============================================================ // Last Run extraction // ============================================================ String extractLastRun(Map status) { String lastEvtDate = "" String lastEvtTime = "" String timeFormat = "" String dateFormat = "" status?.appState?.each { item -> String n = item?.name?.toString() ?: "" if (n == "lastEvtDate") lastEvtDate = item?.value?.toString() ?: "" if (n == "lastEvtTime") lastEvtTime = item?.value?.toString() ?: "" if (n == "timeFormat") timeFormat = item?.value?.toString() ?: "" if (n == "dateFormat") dateFormat = item?.value?.toString() ?: "" } if (!lastEvtDate) return "" java.text.SimpleDateFormat outDateTimeFmt = new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm") java.text.SimpleDateFormat outDateFmt = new java.text.SimpleDateFormat("yyyy-MM-dd") java.text.SimpleDateFormat outTimeFmt = new java.text.SimpleDateFormat("HH:mm") // Determine whether lastEvtDate contains a time component. // A time component is indicated by a colon after the first 6 characters (to skip // over dd-MMM-yyyy's absence of colons) or by AM/PM anywhere in the string. boolean hasTimeComponent = lastEvtDate.toUpperCase().contains("AM") || lastEvtDate.toUpperCase().contains("PM") || lastEvtDate.indexOf(":", 6) >= 0 if (hasTimeComponent) { List fullDateFmts = [ "dd-MMM-yyyy hh:mm:ss a", "dd-MMM-yyyy HH:mm:ss", "dd-MMM-yyyy hh:mm a", "dd-MMM-yyyy HH:mm", "MM/dd/yyyy hh:mm:ss a", "MM/dd/yyyy HH:mm:ss", "yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd hh:mm:ss a" ] for (String fmt : fullDateFmts) { try { return outDateTimeFmt.format(new java.text.SimpleDateFormat(fmt).parse(lastEvtDate)) } catch (Exception ignored) {} } log.warn "extractLastRun: unrecognized full datetime '${lastEvtDate}' — add format to extractLastRun if needed" return "* ${lastEvtDate}" } if (!lastEvtDate.matches(/\d{4}-\d{2}-\d{2}/)) { // Put hub's own dateFormat first so locale-specific formats are tried before fallbacks List dateFmts = (dateFormat ? [dateFormat] : []) + ["dd-MMM-yyyy", "MM/dd/yyyy", "dd/MM/yyyy", "MMM dd, yyyy"] String normalizedDate = null for (String fmt : dateFmts) { try { normalizedDate = outDateFmt.format(new java.text.SimpleDateFormat(fmt).parse(lastEvtDate)) break } catch (Exception ignored) {} } if (normalizedDate) { lastEvtDate = normalizedDate } else { log.warn "extractLastRun: unrecognized date format '${lastEvtDate}' — add format to extractLastRun if needed" lastEvtDate = "* ${lastEvtDate}" } } if (!lastEvtTime) return lastEvtDate List timeFmts = timeFormat ? [timeFormat] : [] timeFmts += ["hh:mm:ss a", "h:mm:ss a", "HH:mm:ss", "hh:mm a", "h:mm a", "HH:mm", "h:mm"] for (String fmt : timeFmts) { try { return "${lastEvtDate} ${outTimeFmt.format(new java.text.SimpleDateFormat(fmt).parse(lastEvtTime))}" } catch (Exception ignored) {} } log.warn "extractLastRun: could not parse time '${lastEvtTime}' (timeFormat='${timeFormat}') — add format to extractLastRun if needed" return "* ${lastEvtDate} ${lastEvtTime}" } // ============================================================ // Shared report assets (CSS + JS) // ============================================================ // Always called from buildReportHtml() — even when rows is empty — so the // built-in table has sortRmLogTable, wildcardToRegex, rmToggle*, etc. available // regardless of whether there are any RM/BC rules to display. String buildSharedReportAssets(String pbEndpoint, String prefEndpoint = "") { StringBuilder sb = new StringBuilder() sb << "" // Embed both endpoint URLs as JS variables using JsonOutput for safe token escaping. sb << "" sb << '''''' return sb.toString() } // ============================================================ // RM/BC table HTML // ============================================================ String buildReportHtml(List rows) { // Compute pbEndpoint before the early-return check so buildSharedReportAssets // always receives it even when rows is empty. // Build the local OAuth endpoint URL for PB toggling (relative — no hub IP). // The access token is embedded in the rendered HTML so the JS click handler can call it. // The token is already scoped to this app and only works on the local network. String pbEndpoint = "" String prefEndpoint = "" if (state.accessToken) { pbEndpoint = "/apps/api/${app.id}/setPB?access_token=${state.accessToken}" prefEndpoint = "/apps/api/${app.id}/setpref?access_token=${state.accessToken}" } else { log.warn "buildReportHtml: no access token — PB cells will render as non-clickable. Re-save the app to generate a token." } StringBuilder sb = new StringBuilder() sb << buildSharedReportAssets(pbEndpoint, prefEndpoint) if (!rows) { sb << "

No rules found. Click Scan All Rules to begin.

" return sb.toString() } // Derive initial button classes from settings // Read custom visibility settings — defaults match original behaviour (only No logging ON hidden) boolean cfgHideRowDisabled = getPref("hideRowDisabled", false) boolean cfgHideRowPaused = getPref("hideRowPaused", false) boolean cfgHideRowLogOff = getPref("hideRowLogOff", true) boolean cfgHideColRuleId = getPref("hideColRuleId", false) boolean cfgHideColAppType = getPref("hideColAppType", false) boolean cfgHideColDisabled = getPref("hideColDisabled", false) boolean cfgHideColPaused = getPref("hideColPaused", false) boolean cfgHideColActions = getPref("hideColActions", false) boolean cfgHideColEvents = getPref("hideColEvents", false) boolean cfgHideColTriggers = getPref("hideColTriggers", false) boolean cfgHideColPB = getPref("hideColPB", false) boolean cfgHideColLastRun = getPref("hideColLastRun", false) String btnRowDisabled = cfgHideRowDisabled ? "rmcol-btn hidden-col" : "rmcol-btn" String btnRowPaused = cfgHideRowPaused ? "rmcol-btn hidden-col" : "rmcol-btn" String btnRowLogOff = cfgHideRowLogOff ? "rmcol-btn hidden-col" : "rmcol-btn" String btnColRuleId = cfgHideColRuleId ? "rmcol-btn hidden-col" : "rmcol-btn" String btnColAppType = cfgHideColAppType ? "rmcol-btn hidden-col" : "rmcol-btn" String btnColDisabled = cfgHideColDisabled ? "rmcol-btn hidden-col" : "rmcol-btn" String btnColPaused = cfgHideColPaused ? "rmcol-btn hidden-col" : "rmcol-btn" String btnColActions = cfgHideColActions ? "rmcol-btn hidden-col" : "rmcol-btn" String btnColEvents = cfgHideColEvents ? "rmcol-btn hidden-col" : "rmcol-btn" String btnColTriggers = cfgHideColTriggers ? "rmcol-btn hidden-col" : "rmcol-btn" String btnColPB = cfgHideColPB ? "rmcol-btn hidden-col" : "rmcol-btn" String btnColLastRun = cfgHideColLastRun ? "rmcol-btn hidden-col" : "rmcol-btn" sb << "
" sb << "Hide rows: " // Row buttons use toggleRmRowFilter() so multiple active filters evaluate together sb << "Disabled rules" sb << "Paused rules" sb << "No logging ON" sb << "  Hide columns: " sb << "Rule ID" sb << "App Type" sb << "Disabled" sb << "Paused" sb << "Events" sb << "Triggers" sb << "Actions" sb << "Private Bool" sb << "Last Run" sb << "  Filter: " sb << "" sb << "
" sb << "" sb << "" sb << "" sb << "" sb << "" sb << "" sb << "" sb << "" sb << "" sb << "" sb << "" sb << "" rows.each { Map r -> String id = htmlEncode(r.id) String nameSort = htmlEncode(r.name?.toString()?.replaceAll(/<[^>]+>/, '') ?: "") String nameHtml = renderNameHtml(r.name) String appType = htmlEncode(r.appType ?: "RM") boolean isBC = (r.appType == "BC") String disabledFmt = formatYesNo(r.disabled as Boolean) String pausedFmt = formatYesNo(r.paused as Boolean) String actionsFmt = formatOnOff(r.actionsOn as Boolean) String eventsFmt = formatOnOff(r.eventsOn as Boolean) String triggersFmt = formatOnOff(r.triggersOn as Boolean) // BC rules have no Events logging option — override cell to non-clickable "—" // and exclude eventsOn from anyOn so the No logging ON filter is not confused. Boolean eventsApplicable = !isBC Boolean anyOn = (r.actionsOn as Boolean) || (eventsApplicable && (r.eventsOn as Boolean)) || (r.triggersOn as Boolean) // privateBool: null = unknown (field absent), true/false = known state Boolean pbVal = r.privateBool == null ? null : (r.privateBool as Boolean) String pbFmt = (pbVal == null) ? "" : pbVal ? "TRUE" : "FALSE" String pbSort = (pbVal == true) ? "2" : (pbVal == false) ? "1" : "0" String lastRun = htmlEncode(r.lastRun ?: "") List trClasses = [] if (r.disabled as Boolean) trClasses << "rmrow-disabled" if (r.paused as Boolean) trClasses << "rmrow-paused" if (!anyOn) trClasses << "rmrow-logoff" String trAttr = trClasses ? " class='${trClasses.join(' ')}'" + ( (cfgHideRowLogOff && !anyOn) || (cfgHideRowDisabled && (r.disabled as Boolean)) || (cfgHideRowPaused && (r.paused as Boolean)) ? " style='display:none'" : "") : "" // When all three share the same field name it's an enum-multiple — JS adds/removes the // specific option rather than flipping the whole field. boolean sharedField = r.actionsField && r.actionsField == r.eventsField && r.actionsField == r.triggersField String actionsFieldType = sharedField ? "enum-multiple" : "bool" String eventsFieldType = sharedField ? "enum-multiple" : "bool" String triggersFieldType = sharedField ? "enum-multiple" : "bool" // Returns a clickable " } String escapedField = htmlEncode(field) String escapedOpt = htmlEncode(enumOpt) return "" } // PB cell: clickable only when endpoint is available AND state is known (not null) String pbTd if (pbEndpoint && pbVal != null) { pbTd = "" } else { pbTd = "" } sb << "" sb << "" sb << "" sb << "" sb << "" sb << "" // BC rules have no Events logging — render a non-clickable "—" cell instead if (isBC) { sb << "" } else { sb << clickableTd("rmcol-events", r.eventsField as String, eventsFieldType, "Events", r.eventsOn as Boolean, r.eventsOn ? "1" : "0", eventsFmt) } sb << clickableTd("rmcol-triggers", r.triggersField as String, triggersFieldType, "Triggers", r.triggersOn as Boolean, r.triggersOn ? "1" : "0", triggersFmt) sb << clickableTd("rmcol-actions", r.actionsField as String, actionsFieldType, "Actions", r.actionsOn as Boolean, r.actionsOn ? "1" : "0", actionsFmt) sb << pbTd sb << "" sb << "" } sb << "
Rule IDRuleApp TypeDisabledPausedEventsTriggersActionsPrivate BoolLast Run
when the field name is known; plain otherwise Closure clickableTd = { String colClass, String field, String fType, String enumOpt, Boolean isOn, String sortVal, String displayHtml -> if (!field) { return "${displayHtml}${displayHtml}${pbFmt}${pbFmt}${id}${nameHtml}${appType}${disabledFmt}${pausedFmt}${lastRun}
" // Emit a deferred init script to apply initial column-hide settings. // Rows are hidden server-side (trAttr above). Columns can't be hidden server-side without // adding inline styles to every cell, so we do it client-side here by setting display:none // directly on each element that carries the column's CSS class. // NOTE: do NOT simulate b.click() here — the toggle bar buttons are already rendered with // the correct hidden-col class server-side, and clicking them would reverse the state. List colClassesToHide = [] if (cfgHideColRuleId) colClassesToHide << "'rmcol-ruleid'" if (cfgHideColAppType) colClassesToHide << "'rmcol-apptype'" if (cfgHideColDisabled) colClassesToHide << "'rmcol-disabled'" if (cfgHideColPaused) colClassesToHide << "'rmcol-paused'" if (cfgHideColActions) colClassesToHide << "'rmcol-actions'" if (cfgHideColEvents) colClassesToHide << "'rmcol-events'" if (cfgHideColTriggers) colClassesToHide << "'rmcol-triggers'" if (cfgHideColPB) colClassesToHide << "'rmcol-pb'" if (cfgHideColLastRun) colClassesToHide << "'rmcol-lastrun'" if (colClassesToHide) { sb << "" } return sb.toString() } // ============================================================ // Built-in app report table // ============================================================ // Columns: Rule ID | Name | App Type | Disabled | Paused | Logging | Last Run // Row classes use bi* prefix to stay independent of the RM/BC table's rm* classes. // Column classes use bi* prefix for the same reason. // applyBiRowFilters / updateBiLogOffClass are defined in the ''' // Derive initial button classes from settings String btnBiRowDisabled = cfgHideBiRowDisabled ? "rmcol-btn hidden-col" : "rmcol-btn" String btnBiRowPaused = cfgHideBiRowPaused ? "rmcol-btn hidden-col" : "rmcol-btn" String btnBiRowLogOff = cfgHideBiRowLogOff ? "rmcol-btn hidden-col" : "rmcol-btn" String btnBiColRuleId = cfgHideBiColRuleId ? "rmcol-btn hidden-col" : "rmcol-btn" String btnBiColAppType = cfgHideBiColAppType ? "rmcol-btn hidden-col" : "rmcol-btn" String btnBiColDisabled = cfgHideBiColDisabled ? "rmcol-btn hidden-col" : "rmcol-btn" String btnBiColPaused = cfgHideBiColPaused ? "rmcol-btn hidden-col" : "rmcol-btn" String btnBiColLogging = cfgHideBiColLogging ? "rmcol-btn hidden-col" : "rmcol-btn" String btnBiColLastRun = cfgHideBiColLastRun ? "rmcol-btn hidden-col" : "rmcol-btn" sb << "
" sb << "Hide rows: " sb << "Disabled rules" sb << "Paused rules" sb << "Logging OFF" sb << "  Hide columns: " sb << "Rule ID" sb << "App Type" sb << "Disabled" sb << "Paused" sb << "Logging" sb << "Last Run" sb << "  Filter: " sb << "" sb << "
" sb << "" sb << "" sb << "" sb << "" sb << "" sb << "" sb << "" sb << "" sb << "" rows.each { Map r -> // already sorted by getBuiltinAppInstances() String id = htmlEncode(r.id) String appType = htmlEncode(r.appType ?: "") String nameHtml = renderNameHtml(r.name) String nameSort = htmlEncode(r.name?.toString()?.replaceAll(/<[^>]+>/, '') ?: "") String disabledFmt = formatYesNo(r.disabled as Boolean) String pausedFmt = formatYesNo(r.paused as Boolean) String lastRun = htmlEncode(r.lastRun ?: "") Boolean logVal = r.logging == null ? null : (r.logging as Boolean) String logFmt = (logVal == null) ? "" : logVal ? "ON" : "OFF" String logSort = (logVal == true) ? "1" : "0" String logTd if (logVal != null) { logTd = "" } else { logTd = "" } // Assign birow-* classes for the row filters; server-side hide if setting active List biTrClasses = [] if (r.disabled as Boolean) biTrClasses << "birow-disabled" if (r.paused as Boolean) biTrClasses << "birow-paused" if (logVal != true) biTrClasses << "birow-logoff" String biTrAttr = biTrClasses ? " class='${biTrClasses.join(' ')}'" + ( (cfgHideBiRowLogOff && logVal != true) || (cfgHideBiRowDisabled && (r.disabled as Boolean)) || (cfgHideBiRowPaused && (r.paused as Boolean)) ? " style='display:none'" : "") : "" sb << "" sb << "" sb << "" sb << "" sb << "" sb << "" sb << logTd sb << "" sb << "" } sb << "
Rule IDNameApp TypeDisabledPausedLoggingLast Run
${logFmt}${logFmt}${id}${nameHtml}${appType}${disabledFmt}${pausedFmt}${lastRun}
" // Column init script — mirrors the RM table approach List biColsToHide = [] if (cfgHideBiColRuleId) biColsToHide << "'bicol-ruleid'" if (cfgHideBiColAppType) biColsToHide << "'bicol-apptype'" if (cfgHideBiColDisabled) biColsToHide << "'bicol-disabled'" if (cfgHideBiColPaused) biColsToHide << "'bicol-paused'" if (cfgHideBiColLogging) biColsToHide << "'bicol-logging'" if (cfgHideBiColLastRun) biColsToHide << "'bicol-lastrun'" if (biColsToHide) { sb << "" } return sb.toString() } // ============================================================ // Formatting helpers // ============================================================ @CompileStatic String formatOnOff(Boolean value) { return value ? "ON" : "OFF" } @CompileStatic String formatYesNo(Boolean value) { return value ? "Yes" : "No" } @CompileStatic String formatScanDuration(Long elapsedMs) { Long safeMs = elapsedMs ?: 0L if (safeMs < 0L) safeMs = 0L Long totalSeconds = Math.round(safeMs / 1000.0D) as Long Long minutes = Math.floor(totalSeconds / 60.0D) as Long Long seconds = totalSeconds % 60L return String.format("%02d:%02d", minutes, seconds) } @CompileStatic String htmlEncode(Object value) { if (value == null) return "" return value.toString() .replace("&", "&") .replace("<", "<") .replace(">", ">") .replace('"', """) .replace("'", "'") } // Encode all HTML, then selectively restore safe color spans so names like // "TEXT" render as colored text rather than // raw markup. Color values are restricted to [a-zA-Z#0-9]+ to prevent injection. @CompileStatic String renderNameHtml(Object value) { if (value == null) return "" String encoded = htmlEncode(value) return encoded.replaceAll( /<span style=(?:'|")color:([a-zA-Z#0-9]+)(?:'|")>(.*?)<\/span>/, "\$2" ) }