victron-vrm-api
Version:
Interface with the Victron Energy VRM API
532 lines (531 loc) • 25.4 kB
JSON
[
{
"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
}
]