Hello all,
I have developed a HomeyScript utility that fetches and filters all Homey flows (standard and advanced) to provide dynamic views for Push notifications, scripts, and code cards.
I would appreciate comments and/or suggestions on the code, and if possible some test results.
/**
* Homey Flow Push, Script and Code Filter Utility
* - Version 2025-12-03
* - Added improved formatted output
* - Added support for variables in Push notification
*
* This script fetches all Homey standard and advanced flows and provides filtered views
* based on current settings. It supports filtering to list:
* - Flow cards that run HomeyScript scripts,
* - Flow cards that run HomeyScript code,
* - Flow cards that run push notifications
* - Or show all flow names unfiltered.
*
* The filtering mode is controlled by the state in oState (PUSH, SCRIPT, CODE, OFF).
*
* Key Features:
* - Retrieve all flows and advanced flows combined (deduplicated).
* - Filter cards by push, script/code name presence, or ID patterns.
* - Show script card names or excerpt code samples with configurable length limit.
* - Process push notification text by replacing [[variable tokens]] with actual values.
* - Provides detailed logging of filtered results and flow counts.
*
* Variable Naming:
* Uses short Hungarian notation for improved type clarity:
* - i for integers or counters
* - a for arrays
* - o for objects
* - s for strings
* - b for booleans
*
* Usage:
* - Jump to `applySettings(StateContainer.PUSH)` for testing:
* - StateContainer.PUSH, StateContainer.SCRIPT, StateContainer.CODE,
* - StateContainer.OFF, StateContainer.OFF_RX
* - The script outputs filtered flow data to the Output log.
*
* Author: Ruurd
* Assistance: Perplexity AI Assistant (for comments)
*/
// State management classes - excellent pattern for type-safe state handling
class StateRecord {
constructor(value, description) {
this.value = value;
this.description = description;
}
}
class StateContainer {
static SHOW_OBJ_ID = true; // Toggle object vs string output format
static CODE = new StateRecord("CODE", "Show CODE samples");
static SCRIPT = new StateRecord("SCRIPT", "Show script names");
static PUSH = new StateRecord("PUSH", "Show Push notification cards contents");
static OFF = new StateRecord("OFF", "No filter, show all flow names");
static OFF_RX = new StateRecord("OFF_RX", "Regex based filter");
static allStates = [
StateContainer.CODE,
StateContainer.SCRIPT,
StateContainer.PUSH,
StateContainer.OFF,
StateContainer.OFF_RX,
];
}
// Initialize settings and load all Homey data (flows + variables)
await applySettings(StateContainer.PUSH); // Change State here for testing
let iCounter = 0; // Total processed flows
let iCounterFilter = 0; // Filtered results count
let iCounterDisabled = 0; // Disabled flows count
let sAllText = ""; // Accumulated output
// Main flow processing loop
for (const oFlow of aAllFlows) {
if (!b_ENABLED_ONLY || oFlow.enabled) { // Skip disabled if b_ENABLED_ONLY=true
if (!bFilter) { // Unfiltered modes (OFF, OFF_RX)
if (oState.state.value === StateContainer.OFF_RX.value) {
// OFF_RX: Sanitize filenames using regex replacements
const sResult = (SHOW_OBJ_ID
? `{name: '${oFlow.name}', id: '${oFlow.id}'}`
: `${oFlow.name.replace(sFilter, (match) => aReplacements[match] || "")}`
);
sAllText += `${sResult}\n`;
iCounter++;
} else if (oState.state.value === StateContainer.OFF.value) {
// OFF: Simple flow name list
sAllText += `${oFlow.name}\n`;
iCounter++;
}
}
// Filtered mode: process cards through filter() function
if (bFilter && oFlow.cards) {
const sResult = filter(oFlow.cards, oFlow);
if (sResult !== null) {
sAllText += `${sResult}\n`;
iCounterFilter++;
}
}
} else {
iCounterDisabled++; // Count skipped disabled flows
}
}
// Generate summary counters
const sCounters =
iCounterFilter === 0
? `(Enabled:${iCounter}, Disabled:${iCounterDisabled})`
: `(Filtered:${iCounterFilter})`;
// Comprehensive logging output with settings summary
log(
`## State: ${JSON.stringify(oState.state)}\n` +
`## Filter ${b_ENABLED_ONLY ? "Enabled" : "All"}: ${bFilter
? typeof sFilter === "string"
? `"${sFilter}"`
: sFilter.toString()
: StateContainer.OFF.value
}\n` +
`## allFlows: ${aAllFlows.length} ${sCounters}\n` +
`## Enabled Flows Only: ${b_ENABLED_ONLY}\n` +
`## Show as Object: ${SHOW_OBJ_ID} (Show as Object with ID)\n\n` +
(SHOW_OBJ_ID ? "const aFlows = [\n" : "") +
sAllText +
(SHOW_OBJ_ID ? "]\n" : "")
);
return iCounter; // Return total processed count
/**
* Initializes global settings and state based on selected mode (SCRIPT/CODE/PUSH/OFF).
* Prepares filtering, variables, and flows for card analysis.
*
* @param {Object} sStateValue - State selector with .value property (e.g. StateContainer.SCRIPT)
* @returns {void}
*/
async function applySettings(sStateValue) {
// Set global constants for code preview length and filename sanitization
globalThis.i_CODE_LEN = 20;
globalThis.b_ENABLED_ONLY = true;
globalThis.aReplacements = {
"\\": "_", // Windows invalid filename chars ā underscores
"/": "_",
":": "_",
"*": "_",
"?": "_",
'"': "_",
"<": "_",
">": "_",
"|": "__",
};
// Reset filtering flags and SHOW_OBJ_ID from state container
globalThis.bScriptCard = false;
globalThis.bFilter = false;
globalThis.sFilter = "";
globalThis.SHOW_OBJ_ID = StateContainer.SHOW_OBJ_ID;
// State machine with evaluate() method that sets mode-specific filters
globalThis.oState = {
state: StateContainer.OFF_RX, // Default initial state
evaluate() {
if (sStateValue.value === StateContainer.SCRIPT.value) {
// SCRIPT mode: filter HomeyScript "run" cards
globalThis.bScriptCard = true;
globalThis.bFilter = true;
globalThis.sFilter = "homey:app:com.athom.homeyscript:run";
return StateContainer.SCRIPT;
} else if (sStateValue.value === StateContainer.CODE.value) {
// CODE mode: filter HomeyScript "runCode" cards
globalThis.bScriptCard = false;
globalThis.bFilter = true;
globalThis.sFilter = "homey:app:com.athom.homeyscript:runCode";
return StateContainer.CODE;
} else if (sStateValue.value === StateContainer.PUSH.value) {
// PUSH mode: filter mobile notifications
globalThis.bScriptCard = false;
globalThis.bFilter = true;
globalThis.sFilter = "homey:manager:mobile";
return StateContainer.PUSH;
} else if (sStateValue.value === StateContainer.OFF.value) {
// OFF mode: disable filtering
globalThis.bFilter = false;
globalThis.sFilter = "";
return StateContainer.OFF;
} else if (sStateValue.value === StateContainer.OFF_RX.value) {
// OFF_RX mode: regex for invalid filename chars only
globalThis.bFilter = false;
globalThis.sFilter = new RegExp("[\\\\/:*?\"<>|]", "g");
return StateContainer.OFF_RX;
} else {
console.log("Unknown state");
return StateContainer.OFF_RX; // Missing return - should default
}
},
};
// Load all Homey data: flows, advanced flows, variables
globalThis.aStandardFlows = Object.values(await Homey.flow.getFlows());
globalThis.aAdvancedFlows = Object.values(await Homey.flow.getAdvancedFlows());
globalThis.aAllFlows = await getAllFlowsRaw(); // Deduplicated combined flows
globalThis.oAllVariables = await Homey.logic.getVariables();
// Flow separator for output formatting
globalThis.sSep = `\n-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!\n`;
// Apply evaluated state (incomplete line: globalThis.sInitial ?)
globalThis.oState.state = globalThis.oState.evaluate();
}
/**
* Filters cards from flows based on state (SCRIPT/CODE/PUSH) and generates summary strings.
*
* @param {Object} oCards - Cards object with card data keyed by ID
* @param {Object} [oFlow={}] - Flow metadata (uses .name for output)
* @returns {string|null} Formatted summary string or null if no matching cards
*/
async function getAllFlowsRaw() {
// Combine standard and advanced flows, deduplicate by ID using Map
const map = new Map();
aStandardFlows.forEach((flow) => map.set(flow.id, flow));
aAdvancedFlows.forEach((flow) => map.set(flow.id, flow));
return Array.from(map.values()); // Return unique flows array
}
function filter(oCards, oFlow = {}) {
// Filter cards based on current state and search filter
const aFilteredEntries = Object.entries(oCards).filter(([sKey, oCard]) => {
if (oState.state.value === StateContainer.SCRIPT.value) {
// SCRIPT mode: match ID filter + require script name
return oCard.id && oCard.id.includes(sFilter) && oCard.args?.script?.name;
} else if (oState.state.value === StateContainer.CODE.value) {
// CODE mode: match ID filter only
return oCard.id && oCard.id.includes(sFilter);
} else if (oState.state.value === StateContainer.PUSH.value) {
// PUSH mode: only allow mobile push notification cards
const allowlist = ["homey:manager:mobile", "homey:manager:mobile:push_text"];
const id = oCard.id || "";
return allowlist.some((prefix) => id.startsWith(prefix));
}
return false;
});
// Rebuild filtered object preserving original keys
const oFilteredCards = aFilteredEntries.reduce((obj, [sKey, oCard]) => {
obj[sKey] = oCard;
return obj;
}, {});
const iFilteredCount = aFilteredEntries.length;
let sCardNames = "", sCode = "", sPushContent = "";
// Generate state-specific summaries
if (oState.state.value === StateContainer.SCRIPT.value) {
// List script names comma-separated
sCardNames = aFilteredEntries
.map(([_, oCard]) => oCard.args.script.name)
.join(", ")
.replace(/, $/, "");
} else if (oState.state.value === StateContainer.CODE.value) {
// Extract code snippets from HomeyScript cards (first i_CODE_LEN chars)
let sCodeSamples = "";
let iCounter = 0;
for (const oCard2 of Object.values(oFilteredCards)) {
if (oCard2.id?.includes("homey:app:com.athom.homeyscript:runCode") || oCard2.args?.code) {
iCounter++;
sCodeSamples += `\n#${iCounter} ${oCard2.args.code.substring(0, i_CODE_LEN).replace(/[\r\n]+|\s{2,}/g, ' ')}`;
}
}
sCode = sCodeSamples !== "" ? sCodeSamples : 'Code not found';
} else if (oState.state.value === StateContainer.PUSH.value) {
// Process mobile push notifications: extract text, replace [[tokens]] with variable values
let iCounter = 0, sRawText = "", sUser = '', sID = '';
for (const oCard2 of Object.values(oFilteredCards)) {
iCounter++;
if (oCard2.id?.includes("homey:manager:mobile:push_text") || oCard2.uri?.includes("homey:manager:mobile")) {
sRawText = oCard2.args?.text || "";
sUser = replaceUsername(oCard2.args?.user.name || '');
sID = oCard2.args?.user.id || "";
if (sRawText) {
// Replace Homey [[tokens]] with actual variable values using tryVariableValue()
let sProcessedText = sRawText.replace(/\[\[([^\[\]]+?)\]\]/g, (match, sTokenContent) => {
// Extract variable name after last '::' or '|' separator
let iLastColon = sTokenContent.lastIndexOf('::');
let iLastPipe = sTokenContent.lastIndexOf('|');
let iSeparatorPos = Math.max(iLastColon, iLastPipe);
if (iSeparatorPos > -1) {
const sVarName = sTokenContent.substring(iSeparatorPos + 1);
const uVarValue = tryVariableValue(sVarName); // Get real variable value
return `[${uVarValue}]`;
}
return 'unknown'; // Fallback for unparseable tokens
});
// Clean up processed text: normalize whitespace, remove newlines
let sCleanText = sProcessedText
.replace(/[\r\n]+/g, ' ')
.replace(/- /g, '')
.replace(/\s{2,}/g, ' ')
.trim();
sPushContent += `#${iCounter} ${sCleanText} ${sUser}\n`;
}
}
}
}
// Return formatted result or null
if (iFilteredCount > 0) {
if (oState.state.value === StateContainer.PUSH.value)
return `${sSep}${oFlow.name}${sSep}${sPushContent.trim()}`;
else if (oState.state.value === StateContainer.SCRIPT.value)
return `${sSep}${oFlow.name}${sSep}${iFilteredCount} ->> ${sCardNames}`;
return `${sSep}${oFlow.name}${sSep}${sCode.trim()}${sSep}`;
}
return null;
}
/**
* Converts a full username to a short @mention format (first 2 chars uppercase).
*
* @param {string} sUserName - Full username (e.g. "john.doe")
* @returns {string} Short @mention like "@JO" or "@@XY"
*/
function replaceUsername(sUserName) {
// Assumes sUserName is a non-empty string; add validation if needed
return `@${sUserName.substring(0, 2).toUpperCase()}`;
}
//
/**
* Attempts to get the value of a HomeyScript variable by its ID.
*
* @param {string} sInput - Input string that might be a variable ID.
* @returns {*} The variable's value if sInput is a valid ID and found,
* otherwise returns the original sInput.
*/
function tryVariableValue(sInput) {
// Check if input is a string and matches UUID format for variable IDs
if (typeof sInput !== 'string' ||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sInput)) {
return sInput; // If not a valid ID, return input unchanged
}
try {
// Search the global variable store for a matching variable by ID
const oVar = Object.values(globalThis.oAllVariables).find(o => o.id === sInput);
if (!oVar) {
console.warn(`Variable with ID "${sInput}" does not exist.`);
return sInput; // Variable not found, return input
}
return oVar.value; // Return the variable's value if found
} catch (oError) {
console.error(`Error getting variable with ID "${sInput}":`, oError);
return sInput; // On error, return the input unchanged or handle otherwise
}
}
Output for Push notifications like:
-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!
sysTEMP
-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!
#1 [time] [date] Pro 2023 TEMP > 65ā ( [measure_temperature] ā ) @RU
-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!
WP POWER
-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!-!
#1 [date] [time] De Warmtepomp werd uitgeschakeld en is weer ingeschakeld. Gebruik Altherma III AAN/UIT. @RU
#2 [date] [time] LETOP Het VERBRUIK van de Warmtepomp/Airco is nu: [measure_power] Watt @RU
#3 [date] [time] WP EXCEPTIONS Het opgenomen vermogen is nu boven de ingestelde waarde van 12,0 kW: [measure_power] Watt. @RU