UNPKG

victron-vrm-api

Version:
292 lines (291 loc) 14.8 kB
[ { "id": "1a1032ac531dcd70", "type": "group", "z": "dd54dd45922062eb", "name": "Price analysis", "style": { "label": true }, "nodes": [ "a32ca3d5b1956c60", "dafc524684caf73e", "3402da69e49f5cc6", "601098dc11ceea8b", "7c289789598b12d0", "583c40137910c702", "65235d7c7d010d74", "7b0a8a20c7f4a196", "9eda047fcfae46cf", "4312e79a1a2a17a5", "cc492cdce20106a1", "0cb71f4f097c34cc" ], "x": 14, "y": 19, "w": 812, "h": 402 }, { "id": "a32ca3d5b1956c60", "type": "function", "z": "dd54dd45922062eb", "g": "1a1032ac531dcd70", "name": "Real-time Status - Am I in a Cheap Period?", "func": "const schedule = msg.payload;\nconst now = Date.now();\n\n// Find top 10 cheapest charging times\nconst cheapestTimes = schedule\n .filter(slot => slot.buyPrice !== null)\n .sort((a, b) => a.buyPrice - b.buyPrice)\n .slice(0, 10);\n\n// Check if current time is in one of the cheap slots\nconst currentSlot = schedule.find(slot => {\n const slotEnd = slot.timestamp + (15 * 60 * 1000); // 15 minutes later\n return now >= slot.timestamp && now < slotEnd;\n});\n\nconst isInCheapPeriod = cheapestTimes.some(slot => \n slot.timestamp === currentSlot?.timestamp\n);\n\nif (isInCheapPeriod) {\n node.status({\n fill: \"green\",\n shape: \"dot\",\n text: `⚡ CHARGE NOW - €${currentSlot.buyPrice.toFixed(3)}/kWh`\n });\n msg.recommendation = \"charge\";\n} else if (currentSlot) {\n // Find how we rank in the price list\n const allPrices = schedule\n .filter(s => s.buyPrice !== null)\n .sort((a, b) => a.buyPrice - b.buyPrice);\n const rank = allPrices.findIndex(s => s.timestamp === currentSlot.timestamp) + 1;\n const percentile = Math.round((rank / allPrices.length) * 100);\n \n node.status({\n fill: \"yellow\",\n shape: \"ring\",\n text: `⏸️ WAIT - Price rank ${rank}/${allPrices.length} (${percentile}%)`\n });\n msg.recommendation = \"wait\";\n} else {\n node.status({\n fill: \"grey\",\n shape: \"ring\",\n text: \"No current price data\"\n });\n msg.recommendation = \"unknown\";\n}\n\nmsg.currentSlot = currentSlot;\nmsg.cheapestTimes = cheapestTimes;\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 390, "y": 140, "wires": [ [ "601098dc11ceea8b" ] ] }, { "id": "dafc524684caf73e", "type": "function", "z": "dd54dd45922062eb", "g": "1a1032ac531dcd70", "name": "Charge / discharge recommendation", "func": "// Recommends CHARGE (green), DISCHARGE (blue), or IDLE (yellow)\nconst schedule = msg.payload;\nconst now = Date.now();\n\nconst cheapestTimes = schedule\n .filter(slot => slot.buyPrice !== null)\n .sort((a, b) => a.buyPrice - b.buyPrice)\n .slice(0, 10);\n\nconst bestSellTimes = schedule\n .filter(slot => slot.sellPrice !== null)\n .sort((a, b) => b.sellPrice - a.sellPrice)\n .slice(0, 10);\n\nconst currentSlot = schedule.find(slot => {\n const slotEnd = slot.timestamp + (15 * 60 * 1000);\n return now >= slot.timestamp && now < slotEnd;\n});\n\nif (!currentSlot) return msg;\n\nconst isChargePeriod = cheapestTimes.some(s => s.timestamp === currentSlot.timestamp);\nconst isDischargePeriod = bestSellTimes.some(s => s.timestamp === currentSlot.timestamp);\n\nif (isChargePeriod) {\n node.status({\n fill: \"green\",\n shape: \"dot\",\n text: `⬇️ CHARGE - €${currentSlot.buyPrice.toFixed(3)}/kWh`\n });\n msg.recommendation = \"charge\";\n} else if (isDischargePeriod) {\n node.status({\n fill: \"blue\",\n shape: \"dot\",\n text: `⬆️ DISCHARGE - €${currentSlot.sellPrice.toFixed(3)}/kWh`\n });\n msg.recommendation = \"discharge\";\n} else {\n node.status({\n fill: \"yellow\",\n shape: \"ring\",\n text: `⏸️ IDLE - €${currentSlot.buyPrice.toFixed(3)}`\n });\n msg.recommendation = \"idle\";\n}\n\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 370, "y": 200, "wires": [ [ "601098dc11ceea8b" ] ] }, { "id": "3402da69e49f5cc6", "type": "function", "z": "dd54dd45922062eb", "g": "1a1032ac531dcd70", "name": "Threshold based smart stats", "func": "// Uses fixed thresholds to determine recommendations\nconst schedule = msg.payload;\nconst now = Date.now();\n\n// Configure your thresholds\nconst CHEAP_THRESHOLD = 0.20; // Below this = charge\nconst EXPENSIVE_THRESHOLD = 0.28; // Above this = avoid\nconst GOOD_SELL_THRESHOLD = 0.12; // Above this = discharge\n\nconst currentSlot = schedule.find(slot => {\n const slotEnd = slot.timestamp + (15 * 60 * 1000);\n return now >= slot.timestamp && now < slotEnd;\n});\n\nif (!currentSlot) return msg;\n\n// Calculate average for comparison\nconst avgPrice = schedule\n .filter(s => s.buyPrice !== null)\n .reduce((sum, s) => sum + s.buyPrice, 0) / schedule.length;\n\nif (currentSlot.buyPrice < CHEAP_THRESHOLD) {\n const savings = ((avgPrice - currentSlot.buyPrice) / avgPrice * 100).toFixed(0);\n node.status({\n fill: \"green\",\n shape: \"dot\",\n text: `⚡ CHARGE NOW - ${savings}% below avg`\n });\n msg.recommendation = \"charge\";\n} else if (currentSlot.buyPrice > EXPENSIVE_THRESHOLD) {\n if (currentSlot.sellPrice > GOOD_SELL_THRESHOLD) {\n node.status({\n fill: \"blue\",\n shape: \"dot\",\n text: `⬆️ DISCHARGE - €${currentSlot.sellPrice.toFixed(3)}`\n });\n msg.recommendation = \"discharge\";\n } else {\n node.status({\n fill: \"red\",\n shape: \"ring\",\n text: `❌ DON'T CHARGE - Too expensive`\n });\n msg.recommendation = \"avoid\";\n }\n} else {\n const diff = Number(((currentSlot.buyPrice - avgPrice) / avgPrice * 100).toFixed(0));\n node.status({\n fill: \"yellow\",\n shape: \"ring\",\n text: `⏸️ MEDIUM - ${diff > 0 ? '+' : ''}${diff}% vs avg`\n });\n msg.recommendation = \"neutral\";\n}\n\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 340, "y": 260, "wires": [ [ "601098dc11ceea8b" ] ] }, { "id": "601098dc11ceea8b", "type": "debug", "z": "dd54dd45922062eb", "g": "1a1032ac531dcd70", "name": "Debugging on price", "active": true, "tosidebar": true, "console": false, "tostatus": false, "complete": "true", "targetType": "full", "statusVal": "", "statusType": "auto", "x": 690, "y": 380, "wires": [] }, { "id": "7c289789598b12d0", "type": "link in", "z": "dd54dd45922062eb", "g": "1a1032ac531dcd70", "name": "link in 1", "links": [ "cc492cdce20106a1" ], "x": 55, "y": 200, "wires": [ [ "583c40137910c702" ] ] }, { "id": "583c40137910c702", "type": "junction", "z": "dd54dd45922062eb", "g": "1a1032ac531dcd70", "x": 120, "y": 200, "wires": [ [ "a32ca3d5b1956c60", "dafc524684caf73e", "3402da69e49f5cc6", "65235d7c7d010d74", "7b0a8a20c7f4a196" ] ] }, { "id": "65235d7c7d010d74", "type": "function", "z": "dd54dd45922062eb", "g": "1a1032ac531dcd70", "name": "Countdown to next cheap period", "func": "// Shows time remaining in current cheap period, or time until next one\nconst schedule = msg.payload;\nconst now = Date.now();\n\nconst cheapestTimes = schedule\n .filter(slot => slot.buyPrice !== null)\n .sort((a, b) => a.buyPrice - b.buyPrice)\n .slice(0, 10);\n\n// Check if currently in cheap period\nconst currentCheap = cheapestTimes.find(slot => {\n const slotEnd = slot.timestamp + (15 * 60 * 1000);\n return now >= slot.timestamp && now < slotEnd;\n});\n\nif (currentCheap) {\n const remaining = (currentCheap.timestamp + 15 * 60 * 1000) - now;\n const minutes = Math.floor(remaining / 60000);\n \n node.status({\n fill: \"green\",\n shape: \"dot\",\n text: `⚡ CHARGING - ${minutes}min left @ €${currentCheap.buyPrice.toFixed(3)}`\n });\n} else {\n // Find next cheap slot\n const nextCheap = cheapestTimes\n .filter(slot => slot.timestamp > now)\n .sort((a, b) => a.timestamp - b.timestamp)[0];\n \n if (nextCheap) {\n const timeUntil = nextCheap.timestamp - now;\n const hours = Math.floor(timeUntil / 3600000);\n const minutes = Math.floor((timeUntil % 3600000) / 60000);\n const timeStr = hours > 0 ? `${hours}h${minutes}m` : `${minutes}m`;\n \n node.status({\n fill: \"yellow\",\n shape: \"ring\",\n text: `⏳ Next cheap slot in ${timeStr} @ €${nextCheap.buyPrice.toFixed(3)}`\n });\n } else {\n node.status({\n fill: \"red\",\n shape: \"ring\",\n text: `❌ No cheap slot upcomming`\n });\n }\n}\n\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 360, "y": 320, "wires": [ [ "601098dc11ceea8b" ] ] }, { "id": "7b0a8a20c7f4a196", "type": "function", "z": "dd54dd45922062eb", "g": "1a1032ac531dcd70", "name": "Planned battery schedule", "func": "// Shows planned SoC vs actual charging windows\nconst schedule = msg.payload;\nconst now = Date.now();\n\n// Find current slot\nconst currentSlot = schedule.find(slot => {\n const slotEnd = slot.timestamp + (15 * 60 * 1000);\n return now >= slot.timestamp && now < slotEnd;\n});\n\nif (!currentSlot) {\n node.status({\n fill: \"grey\",\n shape: \"ring\",\n text: \"No current schedule data\"\n });\n return msg;\n}\n\n// Determine what's happening now based on planned SoC and forecasts\nconst isCharging = currentSlot.batteryForecast > 100; // Charging if > 100W\nconst isDischarging = currentSlot.batteryForecast < -100; // Discharging if < -100W\nconst isExporting = currentSlot.gridForecast < 0; // Exporting to grid\n\nlet statusText = '';\nlet statusColor = 'yellow';\n\nif (isCharging) {\n statusText = `⚡ Charging → ${currentSlot.plannedSoC?.toFixed(1)}% SoC`;\n statusColor = 'green';\n} else if (isDischarging) {\n statusText = `🔋 Discharging → ${currentSlot.plannedSoC?.toFixed(1)}% SoC`;\n statusColor = 'blue';\n} else {\n statusText = `⏸️ Idle - no active (dis)charging`;\n statusColor = 'yellow';\n}\n\n// Add price context\nif (currentSlot.buyPrice) {\n statusText += ` | €${currentSlot.buyPrice.toFixed(3)}`;\n}\n\nnode.status({\n fill: statusColor,\n shape: \"dot\",\n text: statusText\n});\n\nmsg.recommendation = isCharging ? 'charging' : isDischarging ? 'discharging' : 'idle';\nmsg.currentSlot = currentSlot;\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 330, "y": 380, "wires": [ [ "601098dc11ceea8b" ] ] }, { "id": "9eda047fcfae46cf", "type": "inject", "z": "dd54dd45922062eb", "g": "1a1032ac531dcd70", "name": "", "props": [ { "p": "payload" }, { "p": "topic", "vt": "str" } ], "repeat": "", "crontab": "", "once": false, "onceDelay": 0.1, "topic": "", "payload": "", "payloadType": "date", "x": 120, "y": 80, "wires": [ [ "4312e79a1a2a17a5" ] ] }, { "id": "4312e79a1a2a17a5", "type": "vrm-api", "z": "dd54dd45922062eb", "g": "1a1032ac531dcd70", "vrm": "4e54da6b2db12c18", "name": "Fetch Dynamic ESS stats", "api_type": "installations", "idUser": "", "users": "", "idSite": "{{flow.siteId}}", "installations": "stats", "attribute": "dynamic_ess", "stats_interval": "15mins", "show_instance": false, "stats_start": "bod", "stats_end": "eod", "use_utc": false, "gps_start": "", "gps_end": "", "widgets": "", "instance": "", "store_in_global_context": false, "verbose": false, "transform_price_schedule": true, "outputs": 2, "x": 390, "y": 80, "wires": [ [ "0cb71f4f097c34cc" ], [ "cc492cdce20106a1" ] ] }, { "id": "cc492cdce20106a1", "type": "link out", "z": "dd54dd45922062eb", "g": "1a1032ac531dcd70", "name": "link out 1", "mode": "link", "links": [ "7c289789598b12d0" ], "x": 665, "y": 100, "wires": [] }, { "id": "0cb71f4f097c34cc", "type": "debug", "z": "dd54dd45922062eb", "g": "1a1032ac531dcd70", "name": "Raw output", "active": true, "tosidebar": true, "console": false, "tostatus": false, "complete": "payload", "targetType": "msg", "statusVal": "", "statusType": "auto", "x": 710, "y": 60, "wires": [] }, { "id": "4e54da6b2db12c18", "type": "config-vrm-api", "name": "", "forceIpv4": true } ]