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

750 lines (648 loc) 27.1 kB
// Business logic functions for light-saver node // These functions are exported for testing /** * Debug logging wrapper - only logs if debugLog is enabled in config * @param {object} config - Configuration object with debugLog boolean * @param {object} node - Node-RED node object for logging * @param {string} message - Message to log */ function debugLog(config, node, message) { if (config && config.debugLog === true) { node.log(message); } } /** * Parse a timestamp string as UTC, handling timezone offsets * Home Assistant may return timestamps with or without 'Z' or timezone offsets like +00:00 * @param {string} timestamp - ISO timestamp string * @returns {Date} - Date object parsed as UTC */ function parseUTCTimestamp(timestamp) { if (!timestamp) return null; // If timestamp already has 'Z' or a timezone offset (+HH:MM or -HH:MM), use as-is if (timestamp.endsWith("Z") || /[+-]\d{2}:\d{2}$/.test(timestamp)) { return new Date(timestamp); } // Otherwise, add 'Z' to force UTC parsing return new Date(timestamp + "Z"); } /** * Extract brightness level from Home Assistant state object * @param {object} stateObj - State object from Home Assistant * @returns {number|null} - Brightness level 0-100, or null if state is unknown */ function extractBrightnessLevel(stateObj) { if (!stateObj) return null; if (stateObj.state === "off") { return 0; } else if (stateObj.state === "on") { // Check if it has brightness attribute if (stateObj.attributes && stateObj.attributes.brightness !== undefined) { // Convert 0-255 to 0-100 return Math.round((stateObj.attributes.brightness / 255) * 100); } else { // Switch without brightness return 100; } } return null; } /** * Check if a sensor is currently active based on state and invert setting * @param {object} sensor - Sensor object with state property * @param {boolean} invertFlag - Whether to invert the sensor logic * @returns {boolean} - True if sensor is active, false otherwise */ function isSensorActive(sensor, invertFlag) { if (!sensor || sensor.state === undefined || sensor.state === null) { return false; } const sensorState = sensor.state; const isOn = sensorState === "on" || sensorState === true || sensorState === "true"; // If inverted, active when sensor is off return invertFlag ? !isOn : isOn; } /** * Check if it's currently night mode based on night sensor state and invert setting * @param {object} config - Configuration object with nightSensor * @returns {boolean} - True if it's night mode, false otherwise */ function isNightMode(config) { return isSensorActive(config.nightSensor, config.nightSensor?.invert); } /** * Check if it's currently away mode based on away sensor state and invert setting * @param {object} config - Configuration object with awaySensor * @returns {boolean} - True if it's away mode, false otherwise */ function isAwayMode(config) { return isSensorActive(config.awaySensor, config.awaySensor?.invert); } /** * Check if brightness allows lights to turn on based on brightness sensor and limit * @param {object} config - Configuration object with brightnessSensor * @returns {boolean} - True if lights are allowed to turn on, false otherwise */ function isBrightnessAllowingLights(config) { if (!config.brightnessSensor || !config.brightnessSensor.entity_id) { return true; // No brightness limit configured, always allow } if (config.brightnessSensor.limit === null || config.brightnessSensor.limit === undefined) { return true; // No limit set, always allow } if (config.brightnessSensor.state === null || config.brightnessSensor.state === undefined) { return true; // No state yet, allow (fail-open) } const brightness = parseFloat(config.brightnessSensor.state); if (isNaN(brightness)) { return true; // Invalid brightness value, allow (fail-open) } const limit = config.brightnessSensor.limit; const mode = config.brightnessSensor.mode || "max"; if (mode === "min") { // Lights allowed when brightness is ABOVE limit (dark enough) return brightness > limit; } else { // Lights allowed when brightness is BELOW limit (default: max mode) return brightness < limit; } } /** * Handle state change events from Home Assistant * @param {object} event - The state change event from HA * @param {object} config - Configuration object with triggers, lights, nightSensor, etc. * @param {object} state - Mutable state object with timedOut property * @param {object} node - Node-RED node object for logging * @param {object} homeAssistant - Home Assistant integration object * @param {object} clock - Clock abstraction for getting current time (for testing) */ function handleStateChange(event, config, state, node, homeAssistant, clock = null) { const now = clock ? clock.now() : new Date(); debugLog(config, node, "State change event received: " + JSON.stringify(event).substring(0, 200)); if (!event || !event.event) return; const entityId = event.event.entity_id; const newState = event.event.new_state; if (!entityId || !newState) { node.warn(`Event missing entity_id or new_state: ${JSON.stringify(event).substring(0, 100)}`); return; } debugLog(config, node, `Processing state change for ${entityId}: ${newState.state}`); const timestamp = now.toISOString().substring(0, 19); // Format: yyyy-mm-ddTHH:MM:SS const timeOnly = now.toISOString().substring(11, 19); // Format: HH:MM:SS // Check if it's a trigger const trigger = config.triggers.find((t) => t.entity_id === entityId); if (trigger) { trigger.lastChanged = timestamp; trigger.state = newState.state; debugLog(config, node, `Updated trigger ${entityId}: state=${trigger.state}, lastChanged=${trigger.lastChanged}`); // If trigger turned on and timedOut is true, activate lights if (newState.state === "on" && state.timedOut === true) { debugLog( config, node, `Trigger ${entityId} turned on while timedOut=true, checking brightness and activating lights`, ); state.timedOut = false; // Reset timedOut after activating lights // Check brightness limit before turning on lights if (isBrightnessAllowingLights(config)) { const level = findCurrentLevel(config, node, clock); if (level !== null) { controlLights(config, config.lights, level, node, homeAssistant); } } else { debugLog(config, node, "Brightness limit prevents lights from turning on"); } } // Update timedOut status: if any trigger is on, timedOut = false if (newState.state === "on") { state.timedOut = false; } node.status({ fill: "green", shape: "dot", text: `${entityId}: ${newState.state} - updated ${timeOnly}`, }); return; } // Check if it's the night sensor if (config.nightSensor && config.nightSensor.entity_id === entityId) { const wasNightMode = isNightMode(config); // Check if it was night mode before update config.nightSensor.lastChanged = timestamp; config.nightSensor.state = newState.state; debugLog( config, node, `Updated night sensor ${entityId}: state=${config.nightSensor.state}, lastChanged=${config.nightSensor.lastChanged}`, ); node.status({ fill: "green", shape: "dot", text: `Night: ${newState.state} - updated ${timeOnly}`, }); // Return a signal that night sensor turned on/activated (considering invert setting) const isNowNightMode = isNightMode(config); if (!wasNightMode && isNowNightMode) { return { nightSensorTurnedOn: true }; } return; } // Check if it's the away sensor if (config.awaySensor && config.awaySensor.entity_id === entityId) { const wasAwayMode = isAwayMode(config); // Check if it was away mode before update config.awaySensor.lastChanged = timestamp; config.awaySensor.state = newState.state; debugLog( config, node, `Updated away sensor ${entityId}: state=${config.awaySensor.state}, lastChanged=${config.awaySensor.lastChanged}`, ); node.status({ fill: "green", shape: "dot", text: `Away: ${newState.state} - updated ${timeOnly}`, }); // Return a signal that away sensor turned on/activated (considering invert setting) const isNowAwayMode = isAwayMode(config); if (!wasAwayMode && isNowAwayMode) { return { awaySensorTurnedOn: true }; } return; } // Check if it's the brightness sensor if (config.brightnessSensor && config.brightnessSensor.entity_id === entityId) { const wasBrightnessAllowing = isBrightnessAllowingLights(config); // Check before update config.brightnessSensor.lastChanged = timestamp; config.brightnessSensor.state = newState.state; debugLog( config, node, `Updated brightness sensor ${entityId}: state=${config.brightnessSensor.state}, lastChanged=${config.brightnessSensor.lastChanged}`, ); node.status({ fill: "green", shape: "dot", text: `Brightness: ${newState.state} - updated ${timeOnly}`, }); // Check if brightness crossed threshold to allow lights const isNowBrightnessAllowing = isBrightnessAllowingLights(config); if (!wasBrightnessAllowing && isNowBrightnessAllowing) { // Brightness crossed threshold - lights are now allowed // If lights are off and there was motion within timeout (timedOut is false), turn lights on if (state.timedOut === false) { debugLog(config, node, "Brightness crossed threshold and motion detected within timeout, turning lights on"); const level = findCurrentLevel(config, node, clock); if (level !== null) { controlLights(config, config.lights, level, node, homeAssistant); } } } else if (wasBrightnessAllowing && !isNowBrightnessAllowing) { // Brightness crossed threshold - lights are no longer allowed // We don't turn lights off here, let the timeout mechanism handle it debugLog( config, node, "Brightness crossed threshold, lights no longer allowed to turn on (but staying on if already on)", ); } return; } node.warn( `Received state change for ${entityId} but not found in triggers, nightSensor, awaySensor, or brightnessSensor`, ); } /** * Find the level config object for the current time * @param {object} config - Configuration object with levels * @param {object} clock - Clock abstraction for getting current time (for testing) * @returns {object|null} The level config object or null if not found */ function findLevelConfig(config, clock = null) { const now = clock ? clock.now() : new Date(); if (!config.levels || config.levels.length === 0) { return null; } const currentTime = now.getHours() * 60 + now.getMinutes(); // Sort levels by fromTime const sortedLevels = config.levels.slice().sort((a, b) => { const [aHour, aMin] = a.fromTime.split(":").map(Number); const [bHour, bMin] = b.fromTime.split(":").map(Number); const aMinutes = aHour * 60 + aMin; const bMinutes = bHour * 60 + bMin; return aMinutes - bMinutes; }); // Find the latest level that started before current time for (let i = sortedLevels.length - 1; i >= 0; i--) { const [hour, min] = sortedLevels[i].fromTime.split(":").map(Number); const levelTime = hour * 60 + min; if (levelTime <= currentTime) { return sortedLevels[i]; } } return null; } /** * Find the appropriate light level based on time and night sensor * @param {object} config - Configuration object with awaySensor, nightSensor, levels * @param {object} node - Node-RED node object for logging * @param {object} clock - Clock abstraction for getting current time (for testing) * @returns {number|null} The level (0-100) or null if no level found */ function findCurrentLevel(config, node, clock = null) { const now = clock ? clock.now() : new Date(); // Priority: Away sensor > Night sensor > Time-based levels // If away sensor is active and awayLevel is set, use that if (isAwayMode(config) && config.awaySensor?.level !== null && config.awaySensor?.level !== undefined) { debugLog(config, node, `Using away level: ${config.awaySensor.level}%`); return config.awaySensor.level; } // If night sensor is active and nightLevel is set, use that if (isNightMode(config) && config.nightSensor?.level !== null && config.nightSensor?.level !== undefined) { debugLog(config, node, `Using night level: ${config.nightSensor.level}%`); return config.nightSensor.level; } // Otherwise, find level from levels list based on current time if (!config.levels || config.levels.length === 0) { node.warn("No levels defined"); return null; } const currentTime = now.getHours() * 60 + now.getMinutes(); // Current time in minutes since midnight // Sort levels by fromTime const sortedLevels = config.levels.slice().sort((a, b) => { const [aHour, aMin] = a.fromTime.split(":").map(Number); const [bHour, bMin] = b.fromTime.split(":").map(Number); const aMinutes = aHour * 60 + aMin; const bMinutes = bHour * 60 + bMin; return aMinutes - bMinutes; }); // Find the latest level that started before current time let selectedLevel = null; for (let i = sortedLevels.length - 1; i >= 0; i--) { const [hour, min] = sortedLevels[i].fromTime.split(":").map(Number); const levelTime = hour * 60 + min; if (levelTime <= currentTime) { selectedLevel = sortedLevels[i].level; debugLog(config, node, `Found level ${selectedLevel}% from ${sortedLevels[i].fromTime}`); break; } } // If no level found (current time is before all levels), use the last level (wraps from previous day) if (selectedLevel === null && sortedLevels.length > 0) { selectedLevel = sortedLevels[sortedLevels.length - 1].level; debugLog(config, node, `Using last level ${selectedLevel}% (wrapped from previous day)`); } return selectedLevel; } /** * Control lights by sending commands to Home Assistant * @param {object} config - Configuration object with debugLog flag * @param {array} lights - Array of light entities * @param {number} level - Level to set (0-100) * @param {object} node - Node-RED node object for logging * @param {object} homeAssistant - Home Assistant integration object */ function controlLights(config, lights, level, node, homeAssistant) { if (level === null || level === undefined) { node.warn("Cannot control lights: no valid level found"); return; } debugLog(config, node, `Controlling lights with level: ${level}%`); lights.forEach((light) => { const entityId = light.entity_id; const domain = entityId.split(".")[0]; // Store the level we're setting light.setLevel = level; if (domain === "switch") { // For switches: turn off if level is 0, on if level > 0 const service = level === 0 ? "turn_off" : "turn_on"; debugLog(config, node, `Calling ${domain}.${service} for ${entityId}`); homeAssistant.websocket.send({ type: "call_service", domain: domain, service: service, service_data: { entity_id: entityId, }, }); } else if (domain === "light") { // For lights: set brightness percentage if (level === 0) { debugLog(config, node, `Calling ${domain}.turn_off for ${entityId}`); homeAssistant.websocket.send({ type: "call_service", domain: domain, service: "turn_off", service_data: { entity_id: entityId, }, }); } else { debugLog(config, node, `Calling ${domain}.turn_on for ${entityId} with brightness ${level}%`); homeAssistant.websocket.send({ type: "call_service", domain: domain, service: "turn_on", service_data: { entity_id: entityId, brightness_pct: level, }, }); } } }); } /** * Turn off all lights * @param {object} config - Configuration object with debugLog flag * @param {array} lights - Array of light entities * @param {object} node - Node-RED node object for logging * @param {object} homeAssistant - Home Assistant integration object */ function turnOffAllLights(config, lights, node, homeAssistant) { debugLog(config, node, "Turning off all lights (timeout reached)"); lights.forEach((light) => { const entityId = light.entity_id; const domain = entityId.split(".")[0]; // Store that we're setting level to 0 light.setLevel = 0; debugLog(config, node, `Calling ${domain}.turn_off for ${entityId}`); homeAssistant.websocket.send({ type: "call_service", domain: domain, service: "turn_off", service_data: { entity_id: entityId, }, }); }); } /** * Check if triggers have timed out and turn off lights if needed * @param {object} config - Configuration object with triggers, lights, lightTimeout * @param {object} state - Mutable state object with timedOut property * @param {object} node - Node-RED node object for logging * @param {object} homeAssistant - Home Assistant integration object * @param {object} clock - Clock abstraction for getting current time (for testing) */ function checkTimeouts(config, state, node, homeAssistant, clock = null) { const now = clock ? clock.now() : new Date(); // Check if any trigger is on const anyOn = config.triggers.some((t) => t.state === "on"); if (anyOn) { debugLog(config, node, "At least one trigger is on, no timeout check needed"); return; } // All triggers are off, check if they've been off long enough debugLog(config, node, "All triggers are off, checking timeouts..."); let allTimedOut = true; for (const trigger of config.triggers) { // Get timeout for this trigger (use specific timeout or fall back to lightTimeout) const timeoutMinutes = trigger.timeoutMinutes !== undefined ? trigger.timeoutMinutes : config.lightTimeout; // If trigger has no state or lastChanged, we can't check timeout if (!trigger.state || !trigger.lastChanged) { debugLog(config, node, `Trigger ${trigger.entity_id} has no state/lastChanged, skipping`); allTimedOut = false; continue; } // If trigger is on, not timed out if (trigger.state === "on") { allTimedOut = false; continue; } // Calculate how long the trigger has been off const lastChangedTime = parseUTCTimestamp(trigger.lastChanged); const minutesOff = (now - lastChangedTime) / 1000 / 60; debugLog( config, node, `Trigger ${trigger.entity_id}: off for ${minutesOff.toFixed(1)} minutes, timeout is ${timeoutMinutes} minutes`, ); if (minutesOff < timeoutMinutes) { allTimedOut = false; debugLog(config, node, `Trigger ${trigger.entity_id} has not timed out yet`); } } if (allTimedOut && config.triggers.length > 0 && !state.timedOut) { debugLog(config, node, "All triggers have timed out, turning off lights"); turnOffAllLights(config, config.lights, node, homeAssistant); state.timedOut = true; node.status({ fill: "yellow", shape: "ring", text: "Timed out - lights off" }); } // Check for immediate levels when motion is detected (timedOut = false) if (!state.timedOut && !allTimedOut) { const levelConfig = findLevelConfig(config, clock); // Only apply immediate level if this is a NEW immediate period (fromTime changed) if ( levelConfig && levelConfig.immediate === true && levelConfig.fromTime !== state.lastImmediateTime && isBrightnessAllowingLights(config) ) { const currentLevel = findCurrentLevel(config, node, clock); if (currentLevel !== null) { debugLog(config, node, `Immediate level ${currentLevel}% found for time ${levelConfig.fromTime}, applying...`); controlLights(config, config.lights, currentLevel, node, homeAssistant); state.lastImmediateTime = levelConfig.fromTime; // Mark this immediate period as applied } } } else if (state.timedOut) { // Reset lastImmediateTime when timeout occurs (next immediate period will apply) state.lastImmediateTime = null; } } /** * Fetch current states from Home Assistant for entities that don't have state yet * @param {object} config - Configuration object with triggers, nightSensor, lightTimeout * @param {object} state - Mutable state object with timedOut property * @param {object} node - Node-RED node object for logging * @param {object} homeAssistant - Home Assistant integration object * @param {object} clock - Clock abstraction for getting current time (for testing) * @returns {boolean} True if initial timedOut value was set, false otherwise */ function fetchMissingStates(config, state, node, homeAssistant, clock = null) { const entitiesToFetch = []; // Check triggers config.triggers.forEach((trigger) => { if (!trigger.state) { entitiesToFetch.push({ id: trigger.entity_id, type: "trigger" }); } }); // Check night sensor if (config.nightSensor && !config.nightSensor.state) { entitiesToFetch.push({ id: config.nightSensor.entity_id, type: "nightSensor" }); } // Check away sensor if (config.awaySensor && !config.awaySensor.state) { entitiesToFetch.push({ id: config.awaySensor.entity_id, type: "awaySensor" }); } // Check brightness sensor if (config.brightnessSensor && !config.brightnessSensor.state) { entitiesToFetch.push({ id: config.brightnessSensor.entity_id, type: "brightnessSensor" }); } if (entitiesToFetch.length === 0) { debugLog(config, node, "All entities already have states"); } else { debugLog( config, node, `Fetching states for ${entitiesToFetch.length} entities: ${entitiesToFetch.map((e) => e.id).join(", ")}`, ); try { // Access states from the websocket - it's stored as a flat object const states = homeAssistant.websocket.states; debugLog( config, node, `States object type: ${typeof states}, keys count: ${states ? Object.keys(states).length : 0}`, ); if (states && typeof states === "object") { entitiesToFetch.forEach((entity) => { const stateObj = states[entity.id]; debugLog(config, node, `Looking for ${entity.id}, found: ${stateObj ? "yes" : "no"}`); if (stateObj) { if (entity.type === "trigger") { const trigger = config.triggers.find((t) => t.entity_id === entity.id); if (trigger) { trigger.state = stateObj.state; trigger.lastChanged = stateObj.last_changed || stateObj.last_updated; debugLog(config, node, `Fetched state for trigger ${entity.id}: ${stateObj.state}`); } } else if (entity.type === "nightSensor") { config.nightSensor.state = stateObj.state; config.nightSensor.lastChanged = stateObj.last_changed || stateObj.last_updated; debugLog(config, node, `Fetched state for night sensor ${entity.id}: ${stateObj.state}`); } else if (entity.type === "awaySensor") { config.awaySensor.state = stateObj.state; config.awaySensor.lastChanged = stateObj.last_changed || stateObj.last_updated; debugLog(config, node, `Fetched state for away sensor ${entity.id}: ${stateObj.state}`); } else if (entity.type === "brightnessSensor") { config.brightnessSensor.state = stateObj.state; config.brightnessSensor.lastChanged = stateObj.last_changed || stateObj.last_updated; debugLog(config, node, `Fetched state for brightness sensor ${entity.id}: ${stateObj.state}`); } } else { node.warn(`State not found for ${entity.id}`); } }); } else { node.warn("States object not available or not an object"); } } catch (err) { node.warn(`Failed to fetch states: ${err.message}`); node.warn(err.stack); } } // After fetching states, determine initial timedOut value if not yet set if (state.timedOut === undefined && config.triggers.length > 0) { const now = clock ? clock.now() : new Date(); let allTimedOut = true; // Check each trigger to see if it has actually timed out for (const trigger of config.triggers) { // If any trigger is ON, not timed out if (trigger.state === "on") { allTimedOut = false; debugLog(config, node, `Trigger ${trigger.entity_id} is ON, not timed out`); break; } // If trigger is OFF, check how long it's been off if (trigger.state === "off" && trigger.lastChanged) { const timeoutMinutes = trigger.timeoutMinutes !== undefined ? trigger.timeoutMinutes : config.lightTimeout; const lastChangedTime = parseUTCTimestamp(trigger.lastChanged); const minutesOff = (now - lastChangedTime) / 1000 / 60; if (minutesOff < timeoutMinutes) { allTimedOut = false; debugLog( config, node, `Trigger ${trigger.entity_id} off for ${minutesOff.toFixed(1)} min, timeout is ${timeoutMinutes} min - NOT timed out yet`, ); break; } else { debugLog( config, node, `Trigger ${trigger.entity_id} off for ${minutesOff.toFixed(1)} min, timeout is ${timeoutMinutes} min - timed out`, ); } } else if (!trigger.state) { // No state info, can't determine timeout - assume not timed out to be safe allTimedOut = false; debugLog(config, node, `Trigger ${trigger.entity_id} has no state, assuming not timed out`); break; } } state.timedOut = allTimedOut; debugLog( config, node, `Initial timedOut set to ${state.timedOut} (all triggers actually timed out: ${allTimedOut})`, ); if (!allTimedOut) { const levelConfig = findLevelConfig(config, clock); state.lastImmediateTime = levelConfig && levelConfig.immediate === true ? levelConfig.fromTime : null; debugLog( config, node, `Startup preserved current light state while motion is still active${state.lastImmediateTime ? ` (immediate period ${state.lastImmediateTime} already active)` : ""}`, ); } else { state.lastImmediateTime = null; } return true; // Indicates that initial timedOut was set } return false; } module.exports = { parseUTCTimestamp, extractBrightnessLevel, isSensorActive, isNightMode, isAwayMode, isBrightnessAllowingLights, handleStateChange, findCurrentLevel, findLevelConfig, controlLights, turnOffAllLights, checkTimeouts, fetchMissingStates, };