UNPKG

node-red-contrib-power-saver

Version:

A module for Node-RED that you can use to turn on and off a switch based on power prices

767 lines (678 loc) 31.8 kB
module.exports = function (RED) { const funcs = require("./light-saver-functions"); // Timing constants const STARTUP_DELAYS = { HA_CONNECTION_WAIT: 6000, // Wait for HA connection (ms) STATE_FETCH_WAIT: 2000, // Wait for states to be available (ms) IMMEDIATE_CHECK_WAIT: 1000, // Initial timeout check after restart (ms) }; const TIMEOUT_CHECK_INTERVAL = 60000; // Check timeouts every minute (ms) function StrategyLightSaverNode(config) { RED.nodes.createNode(this, config); const node = this; node.status({}); // Store context storage setting node.contextStorage = config.contextStorage || "default"; // Load saved runtime override from context storage let runtimeOverride = null; try { runtimeOverride = node.context().get("override", node.contextStorage); } catch { // Context storage might not be available (e.g., in tests) } // Use runtime override if available, otherwise use config override const override = runtimeOverride !== null && runtimeOverride !== undefined ? runtimeOverride : config.override || "auto"; // Configuration - restructure to separate config from state const nodeConfig = { triggers: Array.isArray(config.triggers) ? config.triggers : [], lights: Array.isArray(config.lights) ? config.lights.map((light) => ({ entity_id: typeof light === "string" ? light : light.entity_id, setLevel: light.setLevel || null, actualLevel: light.actualLevel || null, lastChanged: light.lastChanged || null, })) : [], lightTimeout: config.lightTimeout !== undefined ? config.lightTimeout : 10, nightSensor: config.nightSensor ? { entity_id: config.nightSensor.entity_id || config.nightSensor, state: config.nightSensor.state || null, lastChanged: config.nightSensor.lastChanged || null, level: config.nightLevel !== undefined ? config.nightLevel : null, delay: config.nightDelay !== undefined ? config.nightDelay : 0, invert: config.invertNightSensor === true, } : null, awaySensor: config.awaySensor ? { entity_id: config.awaySensor.entity_id || config.awaySensor, state: config.awaySensor.state || null, lastChanged: config.awaySensor.lastChanged || null, level: config.awayLevel !== undefined ? config.awayLevel : null, delay: config.awayDelay !== undefined ? config.awayDelay : 0, invert: config.invertAwaySensor === true, } : null, brightnessSensor: config.brightnessSensor ? { entity_id: config.brightnessSensor.entity_id || config.brightnessSensor, state: config.brightnessSensor.state || null, lastChanged: config.brightnessSensor.lastChanged || null, limit: config.brightnessLimit !== undefined && config.brightnessLimit !== null ? config.brightnessLimit : null, mode: config.brightnessMode || "max", } : null, levels: Array.isArray(config.levels) ? config.levels : [], debugLog: config.debugLog === true, override: override, }; // Debug logging function (defined early so other functions can use it) const debugLog = function (message) { if (nodeConfig.debugLog) { node.log(message); } }; // Save override to context storage (for persistence across restarts) const saveOverride = function () { try { node.context().set("override", nodeConfig.override, node.contextStorage); debugLog(`Override saved to context storage: ${nodeConfig.override}`); } catch { // Silently fail if context storage not available } }; // Save initial override saveOverride(); let nightActivationTimer = null; // Timer for setting lights to night level when night sensor activates let awayActivationTimer = null; // Timer for setting lights to away level when away sensor activates let startupTimeoutId = null; // Timer for initial state fetch let overrideTimeoutId = null; // Timer for applying override after state fetch let immediateCheckTimeoutId = null; // Timer for immediate timeout check after restart // Create wrapper node object with debug logging const nodeWrapper = { log: debugLog, warn: node.warn.bind(node), error: node.error.bind(node), status: node.status.bind(node), }; // Mutable state const state = { timedOut: undefined, // Tracks if all triggers are currently off lastImmediateTime: null, // Tracks the last fromTime when immediate level was applied }; let timeoutCheckInterval = null; // Timer for checking timeouts every minute if (nodeConfig.triggers.length === 0) { node.status({ fill: "yellow", shape: "ring", text: "No triggers selected" }); node.warn("No triggers selected to monitor"); return; } const serverConfigNode = RED.nodes.getNode(config.server); if (!serverConfigNode) { node.status({ fill: "red", shape: "ring", text: "No server configured" }); node.error("No Home Assistant server configured"); return; } node.status({ fill: "yellow", shape: "ring", text: "Connecting..." }); // Try to get the HA module from require.cache since it's already loaded let haModule = null; const cacheKeys = Object.keys(require.cache); const haModulePath = cacheKeys.find( (k) => k.includes("node-red-contrib-home-assistant-websocket") && k.includes("homeAssistant"), ); if (haModulePath) { try { haModule = require.cache[haModulePath].exports; } catch (e) { node.warn("Failed to load from cache: " + e.message); } } // Try alternative: find the main module exports if (!haModule) { const mainModulePath = cacheKeys.find( (k) => k.includes("node-red-contrib-home-assistant-websocket") && (k.endsWith("index.js") || k.includes("homeAssistant/index")), ); if (mainModulePath) { try { haModule = require.cache[mainModulePath].exports; } catch (e) { node.warn("Failed to load: " + e.message); } } } if (!haModule || !haModule.getHomeAssistant) { node.error("Could not access getHomeAssistant function from HA module"); node.status({ fill: "red", shape: "ring", text: "HA module not accessible" }); return; } const homeAssistant = haModule.getHomeAssistant(serverConfigNode); if (!homeAssistant || !homeAssistant.eventBus) { node.error("Could not get homeAssistant or eventBus"); node.status({ fill: "red", shape: "ring", text: "No eventBus" }); return; } // Function to handle state changes const handleStateChange = function (event) { // If override is active (not 'auto'), don't process state changes if (nodeConfig.override !== "auto") { debugLog("Override active, ignoring state change"); return; } const result = funcs.handleStateChange(event, nodeConfig, state, nodeWrapper, homeAssistant); // Check if night sensor activated (turned on or off if inverted) if (result && result.nightSensorTurnedOn && nodeConfig.nightSensor) { debugLog( `Night sensor activated, scheduling lights to night level after ${nodeConfig.nightSensor.delay} seconds`, ); // Clear any existing timer if (nightActivationTimer) { clearTimeout(nightActivationTimer); } // Set lights to night level after delay nightActivationTimer = setTimeout(() => { if (state.timedOut || !funcs.isNightMode(nodeConfig)) { debugLog("Skipping night level change: lights are off or night mode no longer active"); return; } debugLog("Applying night level to lights"); const level = funcs.findCurrentLevel(nodeConfig, nodeWrapper); if (level !== null) { funcs.controlLights(nodeConfig, nodeConfig.lights, level, nodeWrapper, homeAssistant); node.status({ fill: "blue", shape: "dot", text: `Night mode: ${level}%` }); } else { node.warn("Could not determine level for night mode"); } }, nodeConfig.nightSensor.delay * 1000); } // Check if away sensor activated (turned on or off if inverted) if (result && result.awaySensorTurnedOn && nodeConfig.awaySensor) { debugLog(`Away sensor activated, scheduling lights to away level after ${nodeConfig.awaySensor.delay} seconds`); // Clear any existing timer if (awayActivationTimer) { clearTimeout(awayActivationTimer); } // Set lights to away level after delay awayActivationTimer = setTimeout(() => { if (state.timedOut || !funcs.isAwayMode(nodeConfig)) { debugLog("Skipping away level change: lights are off or away mode no longer active"); return; } debugLog("Applying away level to lights"); const level = nodeConfig.awaySensor.level !== null && nodeConfig.awaySensor.level !== undefined ? nodeConfig.awaySensor.level : 0; funcs.controlLights(nodeConfig, nodeConfig.lights, level, nodeWrapper, homeAssistant); node.status({ fill: "yellow", shape: "dot", text: `Away mode: ${level}%` }); }, nodeConfig.awaySensor.delay * 1000); } }; // Function to handle light state changes const handleLightStateChange = function (event) { debugLog(`Light state change event received: ${JSON.stringify(event).substring(0, 200)}`); if (!event || !event.event) { debugLog("Invalid event structure"); return; } const entityId = event.event.entity_id; const newState = event.event.new_state; if (!newState) { debugLog(`No new state for ${entityId}`); return; } // Find the light in our config const light = nodeConfig.lights.find((l) => l.entity_id === entityId); if (!light) { debugLog(`Light ${entityId} not found in config`); return; } // Extract brightness level using helper function const actualLevel = funcs.extractBrightnessLevel(newState); // Update light state const oldLevel = light.actualLevel; light.actualLevel = actualLevel; light.lastChanged = newState.last_changed || newState.last_updated; debugLog(`Light ${entityId} changed from ${oldLevel}% to ${actualLevel}% at ${light.lastChanged}`); // Send lights list to output node.send({ payload: { lights: nodeConfig.lights, }, }); debugLog(`Sent lights list to output (${nodeConfig.lights.length} lights)`); }; // Function to check timeouts every minute const checkTimeouts = function () { // If override is active (not 'auto'), don't check timeouts if (nodeConfig.override !== "auto") { return; } funcs.checkTimeouts(nodeConfig, state, nodeWrapper, homeAssistant); }; // Function to fetch current states from Home Assistant const fetchMissingStates = function () { funcs.fetchMissingStates(nodeConfig, state, nodeWrapper, homeAssistant); // Fetch initial light states try { const states = homeAssistant.websocket.states; if (states && typeof states === "object") { nodeConfig.lights.forEach((light) => { const stateObj = states[light.entity_id]; if (stateObj) { // Extract brightness level using helper function const actualLevel = funcs.extractBrightnessLevel(stateObj); light.actualLevel = actualLevel; light.lastChanged = stateObj.last_changed || stateObj.last_updated; debugLog(`Fetched initial state for light ${light.entity_id}: ${actualLevel}%`); } else { nodeWrapper.warn(`State not found for light ${light.entity_id}`); } }); } } catch (err) { nodeWrapper.warn(`Failed to fetch light states: ${err.message}`); } // Start timeout check timer if we have state and interval not yet running if (state.timedOut !== undefined && !timeoutCheckInterval) { debugLog("Starting timeout check timer (runs every minute)"); timeoutCheckInterval = setInterval(checkTimeouts, TIMEOUT_CHECK_INTERVAL); // Run an immediate check in case lights should already be turned off after restart debugLog("Running immediate timeout check after restart"); immediateCheckTimeoutId = setTimeout(() => checkTimeouts(), STARTUP_DELAYS.IMMEDIATE_CHECK_WAIT); } }; // Handle input messages node.on("input", function (msg) { let payload = msg.payload; // If payload is a string, try to parse it as JSON if (typeof payload === "string") { try { payload = JSON.parse(payload); } catch (e) { node.warn("Failed to parse payload as JSON: " + e.message); return; } } if (!payload) return; let sendConfigRequested = false; let sendStateRequested = false; // Check for sendConfig/sendState request in commands if (payload.commands && typeof payload.commands === "object") { if (payload.commands.sendConfig === true) { sendConfigRequested = true; } if (payload.commands.sendState === true) { sendStateRequested = true; } } // Check for config updates if (payload.config && typeof payload.config === "object") { // Save old config before changes const oldConfig = JSON.parse(JSON.stringify(nodeConfig)); // Apply config changes if (payload.config.triggers !== undefined) { nodeConfig.triggers = Array.isArray(payload.config.triggers) ? payload.config.triggers : []; } if (payload.config.lights !== undefined) { nodeConfig.lights = Array.isArray(payload.config.lights) ? payload.config.lights : []; } if (payload.config.lightTimeout !== undefined) { nodeConfig.lightTimeout = payload.config.lightTimeout; } if (payload.config.nightSensor !== undefined) { nodeConfig.nightSensor = payload.config.nightSensor; } if (payload.config.awaySensor !== undefined) { nodeConfig.awaySensor = payload.config.awaySensor; } if (payload.config.brightnessSensor !== undefined) { nodeConfig.brightnessSensor = payload.config.brightnessSensor; } if (payload.config.levels !== undefined) { nodeConfig.levels = Array.isArray(payload.config.levels) ? payload.config.levels : []; } if (payload.config.debugLog !== undefined) { nodeConfig.debugLog = payload.config.debugLog; } // Check for override and apply it before sending configs if (payload.config.override !== undefined) { nodeConfig.override = payload.config.override; saveOverride(); } // Send old config node.send({ payload: { oldConfig: oldConfig, }, }); // Clear and restart timeout check interval to use new timeouts if (timeoutCheckInterval) { clearInterval(timeoutCheckInterval); timeoutCheckInterval = setInterval(checkTimeouts, TIMEOUT_CHECK_INTERVAL); } // Send new config (with updated override) node.send({ payload: { newConfig: JSON.parse(JSON.stringify(nodeConfig)), }, }); debugLog("Configuration updated via input"); // Apply override actions immediately after config is sent if (payload.config.override !== undefined) { if (nodeConfig.override === "off") { funcs.turnOffAllLights(nodeConfig, nodeConfig.lights, nodeWrapper, homeAssistant); node.status({ fill: "red", shape: "dot", text: "Override: OFF" }); debugLog("Override: OFF - lights turned off"); } else if (nodeConfig.override === "on") { // Set lights to the appropriate automatic level and keep them there const level = funcs.findCurrentLevel(nodeConfig, nodeWrapper); if (level !== null) { funcs.controlLights(nodeConfig, nodeConfig.lights, level, nodeWrapper, homeAssistant); node.status({ fill: "green", shape: "dot", text: `Override: ON (${level}%)` }); debugLog(`Override: ON - lights set to ${level}% (locked)`); } else { node.warn("Override ON: Could not determine level, using 100%"); funcs.controlLights(nodeConfig, nodeConfig.lights, 100, nodeWrapper, homeAssistant); node.status({ fill: "green", shape: "dot", text: "Override: ON (100%)" }); debugLog("Override: ON - lights set to 100% (fallback)"); } } else if ( typeof nodeConfig.override === "number" && nodeConfig.override >= 0 && nodeConfig.override <= 100 ) { funcs.controlLights(nodeConfig, nodeConfig.lights, nodeConfig.override, nodeWrapper, homeAssistant); node.status({ fill: "green", shape: "dot", text: `Override: ${nodeConfig.override}%` }); debugLog(`Override: ${nodeConfig.override}% - lights set to ${nodeConfig.override}%`); } else if (nodeConfig.override === "auto") { debugLog("Override: AUTO - returned to normal operation"); node.status({ fill: "blue", shape: "dot", text: "Override: AUTO" }); // Set lights to correct level based on timeout state if (state.timedOut === false) { debugLog("Triggers active, setting lights to appropriate level"); const level = funcs.findCurrentLevel(nodeConfig, nodeWrapper); if (level !== null) { funcs.controlLights(nodeConfig, nodeConfig.lights, level, nodeWrapper, homeAssistant); node.status({ fill: "green", shape: "dot", text: `AUTO: ${level}%` }); debugLog(`Lights set to ${level}% (auto mode with active triggers)`); } } else if (state.timedOut === true) { debugLog("Triggers timed out, turning lights off"); funcs.controlLights(nodeConfig, nodeConfig.lights, 0, nodeWrapper, homeAssistant); node.status({ fill: "yellow", shape: "ring", text: "AUTO: Timed out (off)" }); debugLog("Lights turned off (auto mode with timed out triggers)"); } } else { node.warn(`Invalid override value: ${nodeConfig.override}`); } } } // Handle level input (different from override - respects timeout) if (payload.level !== undefined) { const level = payload.level; debugLog(`Level input received: ${level}`); if (level === "off") { funcs.turnOffAllLights(nodeConfig, nodeConfig.lights, nodeWrapper, homeAssistant); debugLog("Level: OFF - lights turned off"); } else if (level === "on") { const currentLevel = funcs.findCurrentLevel(nodeConfig, nodeWrapper); if (currentLevel !== null) { funcs.controlLights(nodeConfig, nodeConfig.lights, currentLevel, nodeWrapper, homeAssistant); debugLog(`Level: ON - lights set to ${currentLevel}%`); } else { node.warn("Level: ON - could not determine level"); } } else if (level === "auto") { // Check timeout and set accordingly if (state.timedOut === false) { const currentLevel = funcs.findCurrentLevel(nodeConfig, nodeWrapper); if (currentLevel !== null) { funcs.controlLights(nodeConfig, nodeConfig.lights, currentLevel, nodeWrapper, homeAssistant); debugLog(`Level: AUTO - lights set to ${currentLevel}% (triggers active)`); } } else { funcs.turnOffAllLights(nodeConfig, nodeConfig.lights, nodeWrapper, homeAssistant); debugLog("Level: AUTO - lights turned off (timed out)"); } } else if (typeof level === "number" && level >= 0 && level <= 100) { funcs.controlLights(nodeConfig, nodeConfig.lights, level, nodeWrapper, homeAssistant); debugLog(`Level: ${level}% - lights set to ${level}%`); } else { node.warn(`Invalid level value: ${level}`); } } // Send config and/or state if requested (combined in one message) if (sendConfigRequested || sendStateRequested) { fetchMissingStates(); const payload = {}; // Add config if requested if (sendConfigRequested) { payload.config = { triggers: nodeConfig.triggers.map((t) => ({ entity_id: t.entity_id, timeoutMinutes: t.timeoutMinutes, })), lights: nodeConfig.lights.map((l) => ({ entity_id: l.entity_id, })), lightTimeout: nodeConfig.lightTimeout, nightSensor: nodeConfig.nightSensor ? { entity_id: nodeConfig.nightSensor.entity_id, level: nodeConfig.nightSensor.level, delay: nodeConfig.nightSensor.delay, invert: nodeConfig.nightSensor.invert, } : null, awaySensor: nodeConfig.awaySensor ? { entity_id: nodeConfig.awaySensor.entity_id, level: nodeConfig.awaySensor.level, delay: nodeConfig.awaySensor.delay, invert: nodeConfig.awaySensor.invert, } : null, levels: nodeConfig.levels, debugLog: nodeConfig.debugLog, override: nodeConfig.override, }; } // Add state if requested if (sendStateRequested) { payload.state = { timedOut: state.timedOut, triggers: nodeConfig.triggers.map((t) => ({ entity_id: t.entity_id, state: t.state, lastChanged: t.lastChanged, timeoutMinutes: t.timeoutMinutes, })), lights: nodeConfig.lights.map((l) => ({ entity_id: l.entity_id, setLevel: l.setLevel, actualLevel: l.actualLevel, lastChanged: l.lastChanged, })), nightSensor: nodeConfig.nightSensor ? { entity_id: nodeConfig.nightSensor.entity_id, state: nodeConfig.nightSensor.state, lastChanged: nodeConfig.nightSensor.lastChanged, level: nodeConfig.nightSensor.level, delay: nodeConfig.nightSensor.delay, invert: nodeConfig.nightSensor.invert, } : null, awaySensor: nodeConfig.awaySensor ? { entity_id: nodeConfig.awaySensor.entity_id, state: nodeConfig.awaySensor.state, lastChanged: nodeConfig.awaySensor.lastChanged, level: nodeConfig.awaySensor.level, delay: nodeConfig.awaySensor.delay, invert: nodeConfig.awaySensor.invert, } : null, override: nodeConfig.override, }; } node.send({ payload }); } }); try { // Subscribe to state_changed events for each trigger nodeConfig.triggers.forEach((trigger) => { const entityId = trigger.entity_id; const eventTopic = `ha_events:state_changed:${entityId}`; homeAssistant.eventBus.on(eventTopic, handleStateChange); debugLog(`Subscribed to ${eventTopic}`); }); // Subscribe to night sensor if configured if (nodeConfig.nightSensor && nodeConfig.nightSensor.entity_id) { const eventTopic = `ha_events:state_changed:${nodeConfig.nightSensor.entity_id}`; homeAssistant.eventBus.on(eventTopic, handleStateChange); debugLog(`Subscribed to night sensor: ${eventTopic}`); } // Subscribe to away sensor if configured if (nodeConfig.awaySensor && nodeConfig.awaySensor.entity_id) { const eventTopic = `ha_events:state_changed:${nodeConfig.awaySensor.entity_id}`; homeAssistant.eventBus.on(eventTopic, handleStateChange); debugLog(`Subscribed to away sensor: ${eventTopic}`); } // Subscribe to brightness sensor if configured if (nodeConfig.brightnessSensor && nodeConfig.brightnessSensor.entity_id) { const eventTopic = `ha_events:state_changed:${nodeConfig.brightnessSensor.entity_id}`; homeAssistant.eventBus.on(eventTopic, handleStateChange); debugLog(`Subscribed to brightness sensor: ${eventTopic}`); } // Subscribe to light state changes nodeConfig.lights.forEach((light) => { const entityId = light.entity_id; const eventTopic = `ha_events:state_changed:${entityId}`; homeAssistant.eventBus.on(eventTopic, handleLightStateChange); debugLog(`Subscribed to light: ${eventTopic}`); }); const nightSensorText = nodeConfig.nightSensor ? ", 1 night sensor" : ""; const awaySensorText = nodeConfig.awaySensor ? ", 1 away sensor" : ""; const brightnessSensorText = nodeConfig.brightnessSensor ? ", 1 brightness sensor" : ""; node.status({ fill: "green", shape: "dot", text: `Monitoring ${nodeConfig.triggers.length} triggers, ${nodeConfig.lights.length} lights${nightSensorText}${awaySensorText}${brightnessSensorText}, ${nodeConfig.levels.length} levels`, }); debugLog( `Monitoring ${nodeConfig.triggers.length} triggers, ${nodeConfig.lights.length} lights${nightSensorText}${awaySensorText}${brightnessSensorText}, and ${nodeConfig.levels.length} levels`, ); // Fetch initial states after a delay to allow HA to connect startupTimeoutId = setTimeout(() => { debugLog("Fetching initial states after startup delay..."); fetchMissingStates(); // Apply override immediately after fetching states overrideTimeoutId = setTimeout(() => { if (nodeConfig.override !== "auto") { debugLog(`Applying override from config: ${nodeConfig.override}`); if (nodeConfig.override === "off") { funcs.turnOffAllLights(nodeConfig, nodeConfig.lights, nodeWrapper, homeAssistant); node.status({ fill: "red", shape: "dot", text: "Override: OFF" }); debugLog("Override: OFF applied on startup"); } else if (nodeConfig.override === "on") { // Set lights to the appropriate automatic level and keep them there const level = funcs.findCurrentLevel(nodeConfig, nodeWrapper); if (level !== null) { funcs.controlLights(nodeConfig, nodeConfig.lights, level, nodeWrapper, homeAssistant); node.status({ fill: "green", shape: "dot", text: `Override: ON (${level}%)` }); debugLog(`Override: ON applied on startup - ${level}% (locked)`); } else { node.warn("Override ON on startup: Could not determine level, using 100%"); funcs.controlLights(nodeConfig, nodeConfig.lights, 100, nodeWrapper, homeAssistant); node.status({ fill: "green", shape: "dot", text: "Override: ON (100%)" }); debugLog("Override: ON applied on startup - 100% (fallback)"); } } else if (typeof nodeConfig.override === "number") { funcs.controlLights(nodeConfig, nodeConfig.lights, nodeConfig.override, nodeWrapper, homeAssistant); node.status({ fill: "green", shape: "dot", text: `Override: ${nodeConfig.override}%` }); debugLog(`Override: ${nodeConfig.override}% applied on startup`); } } }, STARTUP_DELAYS.STATE_FETCH_WAIT); }, STARTUP_DELAYS.HA_CONNECTION_WAIT); } catch (err) { node.status({ fill: "red", shape: "ring", text: "Subscription failed" }); node.error(`Failed to subscribe: ${err.message}`); node.error(err.stack); return; } // Note: Initial states will be populated as state change events come in // The websocket.states object is not populated immediately at startup // Clean up on node close node.on("close", function () { // Clear timeout check interval if (timeoutCheckInterval) { clearInterval(timeoutCheckInterval); timeoutCheckInterval = null; debugLog("Cleared timeout check timer"); } // Clear night activation timer if (nightActivationTimer) { clearTimeout(nightActivationTimer); nightActivationTimer = null; debugLog("Cleared night activation timer"); } // Clear away activation timer if (awayActivationTimer) { clearTimeout(awayActivationTimer); awayActivationTimer = null; debugLog("Cleared away activation timer"); } // Clear startup timeout if (startupTimeoutId) { clearTimeout(startupTimeoutId); startupTimeoutId = null; debugLog("Cleared startup timeout"); } // Clear override timeout if (overrideTimeoutId) { clearTimeout(overrideTimeoutId); overrideTimeoutId = null; debugLog("Cleared override timeout"); } // Clear immediate check timeout if (immediateCheckTimeoutId) { clearTimeout(immediateCheckTimeoutId); immediateCheckTimeoutId = null; debugLog("Cleared immediate check timeout"); } if (homeAssistant && homeAssistant.eventBus) { nodeConfig.triggers.forEach((trigger) => { const entityId = trigger.entity_id; const eventTopic = `ha_events:state_changed:${entityId}`; homeAssistant.eventBus.removeListener(eventTopic, handleStateChange); }); if (nodeConfig.nightSensor && nodeConfig.nightSensor.entity_id) { const eventTopic = `ha_events:state_changed:${nodeConfig.nightSensor.entity_id}`; homeAssistant.eventBus.removeListener(eventTopic, handleStateChange); } if (nodeConfig.awaySensor && nodeConfig.awaySensor.entity_id) { const eventTopic = `ha_events:state_changed:${nodeConfig.awaySensor.entity_id}`; homeAssistant.eventBus.removeListener(eventTopic, handleStateChange); } if (nodeConfig.brightnessSensor && nodeConfig.brightnessSensor.entity_id) { const eventTopic = `ha_events:state_changed:${nodeConfig.brightnessSensor.entity_id}`; homeAssistant.eventBus.removeListener(eventTopic, handleStateChange); } // Unsubscribe from light state changes nodeConfig.lights.forEach((light) => { const entityId = light.entity_id; const eventTopic = `ha_events:state_changed:${entityId}`; homeAssistant.eventBus.removeListener(eventTopic, handleLightStateChange); }); } node.status({}); }); } RED.nodes.registerType("ps-light-saver", StrategyLightSaverNode); };