UNPKG

victron-vrm-api

Version:
532 lines (531 loc) 25.4 kB
[ { "id": "848f13b012f3e09a", "type": "group", "z": "dd54dd45922062eb", "name": "Dynamic ESS mode", "style": { "label": true }, "nodes": [ "f554aa79331525ef", "ccbdb4d7d70c35ed", "a6b5b452ff171afb", "185e05c97e14bd43", "feac1be1f0ea311b" ], "x": 14, "y": 699, "w": 818, "h": 408 }, { "id": "f554aa79331525ef", "type": "inject", "z": "dd54dd45922062eb", "g": "848f13b012f3e09a", "name": "Node-RED mode (4)", "props": [ { "p": "payload" }, { "p": "topic", "vt": "str" } ], "repeat": "", "crontab": "", "once": false, "onceDelay": 0.1, "topic": "", "payload": "4", "payloadType": "num", "x": 150, "y": 820, "wires": [ [ "a6b5b452ff171afb" ] ] }, { "id": "ccbdb4d7d70c35ed", "type": "inject", "z": "dd54dd45922062eb", "g": "848f13b012f3e09a", "name": "VRM mode (1)", "props": [ { "p": "payload" }, { "p": "topic", "vt": "str" } ], "repeat": "", "crontab": "", "once": false, "onceDelay": 0.1, "topic": "", "payload": "1", "payloadType": "num", "x": 130, "y": 780, "wires": [ [ "a6b5b452ff171afb" ] ] }, { "id": "a6b5b452ff171afb", "type": "victron-output-custom", "z": "dd54dd45922062eb", "g": "848f13b012f3e09a", "service": "com.victronenergy.settings", "path": "/Settings/DynamicEss/Mode", "serviceObj": { "service": "com.victronenergy.settings", "name": "com.victronenergy.settings" }, "pathObj": { "path": "/Settings/DynamicEss/Mode", "name": "/Settings/DynamicEss/Mode", "type": "number", "value": 0 }, "name": "", "onlyChanges": false, "x": 590, "y": 780, "wires": [] }, { "id": "185e05c97e14bd43", "type": "inject", "z": "dd54dd45922062eb", "g": "848f13b012f3e09a", "name": "OFF (0)", "props": [ { "p": "payload" }, { "p": "topic", "vt": "str" } ], "repeat": "", "crontab": "", "once": false, "onceDelay": 0.1, "topic": "", "payload": "0", "payloadType": "num", "x": 110, "y": 740, "wires": [ [ "a6b5b452ff171afb" ] ] }, { "id": "feac1be1f0ea311b", "type": "group", "z": "dd54dd45922062eb", "g": "848f13b012f3e09a", "name": "Maintenance", "style": { "label": true }, "nodes": [ "eaab2f6a1a94277b", "6b19c295a1a466ad", "5ab5750481056b3f", "9c6e04864591454e", "22d135f2c1554591", "51bd0153ead472e8" ], "x": 54, "y": 879, "w": 752, "h": 202 }, { "id": "eaab2f6a1a94277b", "type": "inject", "z": "dd54dd45922062eb", "g": "feac1be1f0ea311b", "name": "Clear stored DESS context", "props": [ { "p": "payload" }, { "p": "topic", "vt": "str" } ], "repeat": "", "crontab": "", "once": false, "onceDelay": 0.1, "topic": "", "payload": "clear", "payloadType": "str", "x": 210, "y": 960, "wires": [ [ "6b19c295a1a466ad" ] ], "info": "This allows for starting with a \"fresh\" cache of\nthe schedule. Shouldn't be needed, but you never\nknow when it might come in handy." }, { "id": "6b19c295a1a466ad", "type": "function", "z": "dd54dd45922062eb", "g": "feac1be1f0ea311b", "name": "Maintenance of the stored context", "func": "// Node-RED Function Node: Advanced Maintenance for Dynamic ESS Schedule\n// Supports multiple maintenance actions via msg.action\n\nconst action = msg.action || msg.payload || 'clear'; // Default to 'clear' if not specified\nconst currentTime = Math.floor(Date.now() / 1000);\n\n// Get existing slots\nconst existingSlots = global.get('dynamicEssScheduleSlots') || {};\nconst slotCount = Object.keys(existingSlots).length;\n\nlet result = {};\nlet expiredCount = 0;\n\nswitch (action) {\n case 'clear':\n case 'clear_all':\n // Clear all cached slots\n global.set('dynamicEssScheduleSlots', {});\n node.status({ fill: 'green', shape: 'ring', text: `Cleared ${slotCount} slots` });\n node.warn(`Maintenance: Cleared all ${slotCount} cached schedule slots`);\n \n result = {\n action: 'clear_all',\n clearedSlots: slotCount,\n timestamp: new Date().toISOString()\n };\n break;\n \n case 'clear_expired':\n // Remove only expired slots\n const cleaned = {};\n \n for (const [slotNum, slotData] of Object.entries(existingSlots)) {\n const endTime = slotData.Start + slotData.Duration;\n if (endTime >= currentTime) {\n // Keep non-expired\n cleaned[slotNum] = slotData;\n } else {\n expiredCount++;\n }\n }\n \n global.set('dynamicEssScheduleSlots', cleaned);\n node.status({ fill: 'blue', shape: 'ring', text: `Removed ${expiredCount} expired` });\n node.warn(`Maintenance: Removed ${expiredCount} expired slots, kept ${Object.keys(cleaned).length}`);\n \n result = {\n action: 'clear_expired',\n removedSlots: expiredCount,\n remainingSlots: Object.keys(cleaned).length,\n timestamp: new Date().toISOString()\n };\n break;\n \n case 'info':\n case 'status':\n // Show current status without clearing\n let activeCount = 0;\n \n for (const [slotNum, slotData] of Object.entries(existingSlots)) {\n const endTime = slotData.Start + slotData.Duration;\n if (endTime >= currentTime) {\n activeCount++;\n } else {\n expiredCount++;\n }\n }\n \n node.status({ fill: 'yellow', shape: 'dot', text: `${activeCount} active, ${expiredCount} expired` });\n \n result = {\n action: 'info',\n totalSlots: slotCount,\n activeSlots: activeCount,\n expiredSlots: expiredCount,\n availableSlots: 48 - activeCount,\n timestamp: new Date().toISOString()\n };\n break;\n \n case 'dump':\n case 'export':\n // Export all slots for inspection\n node.status({ fill: 'grey', shape: 'dot', text: `Exported ${slotCount} slots` });\n \n result = {\n action: 'export',\n slots: existingSlots,\n totalSlots: slotCount,\n timestamp: new Date().toISOString()\n };\n break;\n \n default:\n node.status({ fill: 'red', shape: 'ring', text: 'Unknown action' });\n node.error(`Unknown maintenance action: ${action}. Use: clear, clear_expired, info, or dump`);\n \n result = {\n error: 'Unknown action',\n action: action,\n validActions: ['clear', 'clear_expired', 'info', 'dump']\n };\n}\n\nmsg.payload = result;\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 540, "y": 920, "wires": [ [ "9c6e04864591454e" ] ] }, { "id": "5ab5750481056b3f", "type": "inject", "z": "dd54dd45922062eb", "g": "feac1be1f0ea311b", "name": "Dump the stored DESS context", "props": [ { "p": "payload" }, { "p": "topic", "vt": "str" } ], "repeat": "", "crontab": "", "once": false, "onceDelay": 0.1, "topic": "", "payload": "dump", "payloadType": "str", "x": 230, "y": 1040, "wires": [ [ "6b19c295a1a466ad" ] ] }, { "id": "9c6e04864591454e", "type": "debug", "z": "dd54dd45922062eb", "g": "feac1be1f0ea311b", "name": "DESS maintenance", "active": true, "tosidebar": true, "console": false, "tostatus": false, "complete": "true", "targetType": "full", "statusVal": "", "statusType": "auto", "x": 670, "y": 980, "wires": [] }, { "id": "22d135f2c1554591", "type": "inject", "z": "dd54dd45922062eb", "g": "feac1be1f0ea311b", "name": "DESS context status", "props": [ { "p": "payload" }, { "p": "topic", "vt": "str" } ], "repeat": "", "crontab": "", "once": false, "onceDelay": 0.1, "topic": "", "payload": "status", "payloadType": "str", "x": 190, "y": 920, "wires": [ [ "6b19c295a1a466ad" ] ] }, { "id": "51bd0153ead472e8", "type": "inject", "z": "dd54dd45922062eb", "g": "feac1be1f0ea311b", "name": "Clear expired DESS context", "props": [ { "p": "payload" }, { "p": "topic", "vt": "str" } ], "repeat": "", "crontab": "", "once": false, "onceDelay": 0.1, "topic": "", "payload": "clear_expired", "payloadType": "str", "x": 220, "y": 1000, "wires": [ [ "6b19c295a1a466ad" ] ], "info": "This allows for starting with a \"fresh\" cache of\nthe schedule. Shouldn't be needed, but you never\nknow when it might come in handy." }, { "id": "d9455186613b5f12", "type": "group", "z": "dd54dd45922062eb", "name": "Dynamic ESS scheduling", "style": { "label": true }, "nodes": [ "91794f12bc40ad46", "10ad53090e1dc735", "2164b4a2d865544a", "5672407a3f8c36e0", "ec2e83aca6c22c8c", "14a36f4c55a0ae1a", "792b8b1d92e76bb8", "f22621fcf6b896e1" ], "x": 14, "y": 479, "w": 812, "h": 202 }, { "id": "91794f12bc40ad46", "type": "vrm-api", "z": "dd54dd45922062eb", "g": "d9455186613b5f12", "vrm": "4e54da6b2db12c18", "name": "Fetch Dynamic ESS schedules", "api_type": "installations", "idUser": "", "users": "", "idSite": "{{flow.siteId}}", "installations": "fetch-dynamic-ess-schedules", "attribute": "dynamic_ess", "stats_interval": "15mins", "show_instance": false, "stats_start": "", "stats_end": "", "use_utc": false, "gps_start": "", "gps_end": "", "widgets": "", "instance": "", "store_in_global_context": false, "verbose": false, "transform_price_schedule": false, "outputs": 1, "x": 430, "y": 520, "wires": [ [ "2164b4a2d865544a" ] ] }, { "id": "10ad53090e1dc735", "type": "inject", "z": "dd54dd45922062eb", "g": "d9455186613b5f12", "name": "", "props": [ { "p": "payload" }, { "p": "topic", "vt": "str" } ], "repeat": "900", "crontab": "", "once": true, "onceDelay": "1", "topic": "", "payload": "", "payloadType": "date", "x": 130, "y": 520, "wires": [ [ "91794f12bc40ad46" ] ] }, { "id": "2164b4a2d865544a", "type": "link out", "z": "dd54dd45922062eb", "g": "d9455186613b5f12", "name": "link out 2", "mode": "link", "links": [ "f22621fcf6b896e1" ], "x": 705, "y": 520, "wires": [] }, { "id": "5672407a3f8c36e0", "type": "function", "z": "dd54dd45922062eb", "g": "d9455186613b5f12", "name": "Process Dynamic ESS schedule", "func": "// Node-RED Function Node: Process Dynamic ESS Schedule\n// Place this code in a Function node after your VRM API fetch node\n\nconst MAX_SLOTS = 4;\nconst PROPERTIES = ['Duration', 'Start', 'AllowGridFeedin', 'Soc', 'Restrictions', 'Flags', 'Strategy'];\nconst allowNonEqualArrays = false; // Set to false to enforce strict array length validation\n\n// Get current time (use msg.timestamp for testing, otherwise Date.now())\nconst currentTime = msg.timestamp || Math.floor(Date.now() / 1000);\n\n// Extract schedule from payload\nif (!msg.payload || !msg.payload.schedule) {\n node.status({ fill: 'red', shape: 'ring', text: 'Invalid input' });\n node.error('Invalid message: msg.payload.schedule is required');\n return null;\n}\n\nconst schedule = msg.payload.schedule;\n\n// Validate schedule structure\ntry {\n validateSchedule(schedule, PROPERTIES, allowNonEqualArrays);\n} catch (error) {\n node.status({ fill: 'red', shape: 'ring', text: error.message });\n node.error(error.message);\n return null;\n}\n\n// Get existing slots from global context\nconst existingSlots = global.get('dynamicEssScheduleSlots') || {};\n\n// Identify expired slots\nconst expiredSlotNumbers = findExpiredSlots(existingSlots, currentTime);\n\n// Build new slot assignments and collect changes\nconst { newSlots, changes, stats } = processScheduleEntries(\n schedule,\n existingSlots,\n expiredSlotNumbers,\n currentTime,\n MAX_SLOTS,\n PROPERTIES\n);\n\n// Save updated slots to context\nglobal.set('dynamicEssScheduleSlots', newSlots);\n\n// Update node status\nupdateNodeStatus(node, stats, changes.length);\n\n// Return array of change messages\nreturn [changes];\n\n// ===== Helper Functions =====\n\nfunction validateSchedule(schedule, properties, allowNonEqual) {\n // Check all required properties exist\n for (const prop of properties) {\n if (!Array.isArray(schedule[prop])) {\n throw new Error(`Schedule property '${prop}' must be an array`);\n }\n }\n \n // Check all arrays have same length\n const lengths = properties.map(prop => schedule[prop].length);\n const minLength = Math.min(...lengths);\n const maxLength = Math.max(...lengths);\n \n if (minLength !== maxLength) {\n if (allowNonEqual) {\n // Truncate all arrays to minimum length\n node.warn(`Array length mismatch detected. Truncating to ${minLength} entries (shortest array).`);\n for (const prop of properties) {\n if (schedule[prop].length > minLength) {\n schedule[prop].length = minLength;\n }\n }\n } else {\n throw new Error('All schedule arrays must have the same length');\n }\n }\n}\n\nfunction findExpiredSlots(slots, currentTime) {\n const expired = [];\n \n for (const [slotNum, slotData] of Object.entries(slots)) {\n const endTime = slotData.Start + slotData.Duration;\n if (endTime < currentTime) {\n expired.push(parseInt(slotNum));\n }\n }\n \n return expired.sort((a, b) => a - b);\n}\n\nfunction processScheduleEntries(schedule, existingSlots, expiredSlotNumbers, currentTime, maxSlots, properties) {\n const newSlots = { ...existingSlots };\n const changes = [];\n const scheduleLength = schedule.Start.length;\n \n // Statistics for status reporting\n const stats = {\n totalEntries: scheduleLength,\n expiredEntries: 0,\n processedEntries: 0,\n newSlots: 0,\n updatedSlots: 0,\n unchangedSlots: 0,\n discardedOverLimit: 0\n };\n \n // Create a map of Start time to existing slot number\n const startTimeToSlot = {};\n for (const [slotNum, slotData] of Object.entries(existingSlots)) {\n startTimeToSlot[slotData.Start] = parseInt(slotNum);\n }\n \n // Track which slots we've assigned\n const usedSlots = new Set(Object.keys(existingSlots).map(n => parseInt(n)));\n \n // Process each schedule entry and filter expired ones\n const scheduleEntries = [];\n for (let i = 0; i < scheduleLength; i++) {\n const entry = {\n Duration: schedule.Duration[i],\n Start: schedule.Start[i],\n AllowGridFeedin: schedule.AllowGridFeedin[i],\n Soc: schedule.Soc[i],\n Restrictions: schedule.Restrictions[i],\n Flags: schedule.Flags[i],\n Strategy: schedule.Strategy[i]\n };\n \n // Skip expired entries\n const endTime = entry.Start + entry.Duration;\n if (endTime < currentTime) {\n stats.expiredEntries++;\n continue;\n }\n \n scheduleEntries.push(entry);\n }\n \n // If we have more than maxSlots non-expired entries, keep only first maxSlots\n // Sort by Start time and limit to maxSlots\n if (scheduleEntries.length > maxSlots) {\n scheduleEntries.sort((a, b) => a.Start - b.Start);\n stats.discardedOverLimit = scheduleEntries.length - maxSlots;\n scheduleEntries.length = maxSlots;\n }\n \n // Track available expired slots for reuse\n let availableExpiredSlots = [...expiredSlotNumbers];\n \n // Process each schedule entry\n for (const entry of scheduleEntries) {\n let slotNum;\n let hasChanges = false;\n \n // Check if this Start time already exists in a non-expired slot\n if (startTimeToSlot[entry.Start] !== undefined) {\n slotNum = startTimeToSlot[entry.Start];\n \n // Compare and collect changes\n const existingEntry = existingSlots[slotNum];\n for (const prop of properties) {\n if (existingEntry[prop] !== entry[prop]) {\n changes.push({\n path: `/Settings/DynamicEss/Schedule/${slotNum}/${prop}`,\n payload: entry[prop]\n });\n hasChanges = true;\n }\n }\n \n if (hasChanges) {\n stats.updatedSlots++;\n } else {\n stats.unchangedSlots++;\n }\n \n // Update slot data\n newSlots[slotNum] = entry;\n } else {\n // New entry - find a slot for it\n \n // First, try to reuse an expired slot\n if (availableExpiredSlots.length > 0) {\n slotNum = availableExpiredSlots.shift();\n } else {\n // No expired slots, find next available slot number\n slotNum = 0;\n while (usedSlots.has(slotNum) && slotNum < maxSlots) {\n slotNum++;\n }\n \n if (slotNum >= maxSlots) {\n // All slots full - this should not happen since we limited to maxSlots above\n node.warn(`Unexpected: Could not assign slot for entry with Start=${entry.Start}`);\n continue;\n }\n }\n \n // Write all properties for new slot\n for (const prop of properties) {\n changes.push({\n path: `/Settings/DynamicEss/Schedule/${slotNum}/${prop}`,\n payload: entry[prop]\n });\n }\n \n stats.newSlots++;\n \n // Update tracking\n newSlots[slotNum] = entry;\n usedSlots.add(slotNum);\n startTimeToSlot[entry.Start] = slotNum;\n }\n \n stats.processedEntries++;\n }\n \n return { newSlots, changes, stats };\n}\n\nfunction updateNodeStatus(node, stats, changeCount) {\n if (changeCount === 0) {\n node.status({ \n fill: 'green', \n shape: 'dot', \n text: `No changes (${stats.processedEntries} slots)` \n });\n } else if (stats.newSlots > 0) {\n node.status({ \n fill: 'blue', \n shape: 'dot', \n text: `${changeCount} changes (${stats.newSlots} new, ${stats.updatedSlots} updated)` \n });\n } else {\n node.status({ \n fill: 'yellow', \n shape: 'dot', \n text: `${changeCount} changes (${stats.updatedSlots} updated)` \n });\n }\n \n // Build warning message\n const discardedParts = [];\n if (stats.expiredEntries > 0) {\n discardedParts.push(`${stats.expiredEntries} expired`);\n }\n if (stats.discardedOverLimit > 0) {\n discardedParts.push(`${stats.discardedOverLimit} over 48 limit`);\n }\n \n if (discardedParts.length > 0) {\n const totalDiscarded = stats.expiredEntries + stats.discardedOverLimit;\n node.warn(`Ignored ${totalDiscarded} of ${stats.totalEntries} entries: ${discardedParts.join(', ')}`);\n }\n}", "outputs": 1, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 310, "y": 580, "wires": [ [ "792b8b1d92e76bb8", "ec2e83aca6c22c8c" ] ] }, { "id": "ec2e83aca6c22c8c", "type": "debug", "z": "dd54dd45922062eb", "g": "d9455186613b5f12", "name": "Updated DESS schedules", "active": true, "tosidebar": true, "console": false, "tostatus": true, "complete": "true", "targetType": "full", "statusVal": "path", "statusType": "msg", "x": 670, "y": 580, "wires": [] }, { "id": "14a36f4c55a0ae1a", "type": "victron-output-custom", "z": "dd54dd45922062eb", "g": "d9455186613b5f12", "service": "com.victronenergy.settings", "path": "/Settings/DynamicEss/Schedule/0/Start", "serviceObj": { "service": "com.victronenergy.settings", "name": "com.victronenergy.settings" }, "pathObj": { "path": "/Settings/DynamicEss/Schedule/0/Start", "name": "/Settings/DynamicEss/Schedule/0/Start", "type": "number", "value": 0 }, "name": "Update DESS schedules", "onlyChanges": false, "x": 670, "y": 640, "wires": [] }, { "id": "792b8b1d92e76bb8", "type": "delay", "z": "dd54dd45922062eb", "g": "d9455186613b5f12", "name": "Be a bit friendly for the dbus", "pauseType": "delay", "timeout": "0.1", "timeoutUnits": "seconds", "rate": "1", "nbRateUnits": "1", "rateUnits": "second", "randomFirst": "1", "randomLast": "5", "randomUnits": "seconds", "drop": false, "allowrate": false, "outputs": 1, "x": 320, "y": 640, "wires": [ [ "14a36f4c55a0ae1a" ] ] }, { "id": "f22621fcf6b896e1", "type": "link in", "z": "dd54dd45922062eb", "g": "d9455186613b5f12", "name": "link in 1", "links": [ "2164b4a2d865544a" ], "x": 95, "y": 580, "wires": [ [ "5672407a3f8c36e0" ] ] }, { "id": "4e54da6b2db12c18", "type": "config-vrm-api", "name": "", "forceIpv4": true } ]