Requesting Feedback on HomeyScript Flow Push, Script, and Code Filter Utility

Ruurd_van_der_Noord
Thu, 04 Dec 2025 06:32:54 +0000
Questions & Help

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

4 posts - 2 participants