Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save jvhaarst/dea4a1b09a4bd82d2ca7af5d5570b68c to your computer and use it in GitHub Desktop.

Select an option

Save jvhaarst/dea4a1b09a4bd82d2ca7af5d5570b68c to your computer and use it in GitHub Desktop.
[
{
"id": "0262df3f88e799c6",
"type": "tab",
"label": "EVCS Solar Surplus",
"disabled": false,
"info": "EVCS Solar Surplus Control\n===========================\nControls the Victron EVCS in Manual mode based on solar surplus,\nkeeping a configurable buffer (default 200W) flowing to the grid\nto avoid drawing from grid while charging.\n\nPhase mode (1P/3P) is detected automatically from EVCS L2 power.\n/MaxCurrent (reg 5017) is read live and used as the override ceiling.\n\nGRID CHARGE OVERRIDE\n────────────────────\nTwo ways to trigger a full-speed grid charge:\n\n 1. Press the MODE button on the EVCS display.\n Node-RED detects any non-Manual mode (Auto OR Scheduled),\n flips the override, and reverts Mode back to Manual (<1s).\n Note: the display button cycles Manual→Auto→Scheduled→Manual.\n Either Auto or Scheduled press is treated as a toggle.\n\n 2. Click the \"⚡ Force grid charge\" inject button in Node-RED.\n\nTo return to solar surplus control:\n - Press MODE button again (any non-Manual press)\n - Click \"☀ Resume solar\" inject button\n\nHOW TO EDIT CONSTANTS\n─────────────────────\n1. Double-click the \"EVCS Solar Surplus\" tab name\n2. Click the list icon (≡) in the top-right of the dialog\n3. Edit values under \"Environment Variables\"\n4. Click Done → Deploy\n\nENVIRONMENT VARIABLES\n─────────────────────\nEVCS_BUFFER_W W to keep flowing to grid (default: 200)\nEVCS_MAX_A Fallback max current in amps (default: 16)\nEVCS_STOP_DELAY_S Seconds below 6A before stopping (default: 30)\nEVCS_START_DELAY_S Seconds above 6A before starting (default: 60)\nEVCS_PHASE_DETECT_W EVCS L2 power threshold for 3P (default: 50)",
"env": [
{
"name": "EVCS_BUFFER_W",
"value": "200",
"type": "num"
},
{
"name": "EVCS_MAX_A",
"value": "16",
"type": "num"
},
{
"name": "EVCS_STOP_DELAY_S",
"value": "30",
"type": "num"
},
{
"name": "EVCS_START_DELAY_S",
"value": "60",
"type": "num"
},
{
"name": "EVCS_PHASE_DETECT_W",
"value": "50",
"type": "num"
}
]
},
{
"id": "53d3b08cf818d2e0",
"type": "group",
"z": "0262df3f88e799c6",
"name": "Initialisation — sets Mode=0 (Manual) once on deploy",
"style": {
"stroke": "#999999",
"fill": "none",
"label": true,
"label-position": "nw",
"color": "#a4a4a4"
},
"nodes": [
"18d6e996623db222",
"c949a2be3e9208fa",
"44615a1601fcfb8a"
],
"x": 14,
"y": 59,
"w": 652,
"h": 82
},
{
"id": "e49f4739e7a270e0",
"type": "group",
"z": "0262df3f88e799c6",
"name": "Sensor Inputs",
"style": {
"stroke": "#999999",
"fill": "none",
"label": true,
"label-position": "nw",
"color": "#a4a4a4"
},
"nodes": [
"d3e99a0740e7c04b",
"bd7320a3915994e3",
"6a032699471d513e",
"5c737be3eec6f5d6",
"e8313c4d38e6ccbb",
"efa89831788ee131",
"9192ec79887859e3",
"e3b85f2603d6e13d",
"8253e0136744d27b",
"e7d67300c7a31008",
"3dd82bf8ceb2effd",
"fb738d03688a3f6f",
"ef9760b5c32bbe71",
"0e8adf658ce77204",
"8050ccab6c42918c",
"020290bac7b2e065",
"332dbf5c924a80a8",
"59946e4ad6cab677",
"e4c7b429127bb18c"
],
"x": 14,
"y": 199,
"w": 892,
"h": 562
},
{
"id": "f9c1da2ca51b0d7b",
"type": "group",
"z": "0262df3f88e799c6",
"name": "Control Logic",
"style": {
"stroke": "#999999",
"fill": "none",
"label": true,
"label-position": "nw",
"color": "#a4a4a4"
},
"nodes": [
"0d44efc40a213ad5"
],
"x": 954,
"y": 424,
"w": 252,
"h": 112
},
{
"id": "2c122f5c6e60ae15",
"type": "group",
"z": "0262df3f88e799c6",
"name": "Outputs",
"style": {
"stroke": "#999999",
"fill": "none",
"label": true,
"label-position": "nw",
"color": "#a4a4a4"
},
"nodes": [
"82cae138390cd13d",
"394575d72837dea4",
"96146a4497e7bcd7",
"65d767d1d7903f78",
"3ff86adc0770c5dd"
],
"x": 1364,
"y": 319,
"w": 402,
"h": 222
},
{
"id": "d6a4424e7a78e5cb",
"type": "group",
"z": "0262df3f88e799c6",
"name": "Debug — disable active flag in production",
"style": {
"stroke": "#999999",
"fill": "none",
"label": true,
"label-position": "nw",
"color": "#a4a4a4"
},
"nodes": [
"f0321f629373f7f5"
],
"x": 1354,
"y": 579,
"w": 270,
"h": 82
},
{
"id": "015de6af7d2ac5db",
"type": "group",
"z": "0262df3f88e799c6",
"name": "Override — inject buttons OR mode button on EVCS display",
"style": {
"stroke": "#ff9800",
"fill": "none",
"label": true,
"label-position": "nw",
"color": "#ff9800"
},
"nodes": [
"8099d31cac5e8ebf",
"0b3b753ba9aab4c1",
"ddec341471deedd6",
"ddce531852010ae4"
],
"x": 14,
"y": 819,
"w": 492,
"h": 142
},
{
"id": "victron-client-id",
"type": "victron-client",
"showValues": true,
"contextStore": true,
"enablePolling": false
},
{
"id": "18d6e996623db222",
"type": "inject",
"z": "0262df3f88e799c6",
"g": "53d3b08cf818d2e0",
"name": "On deploy",
"props": [
{
"p": "payload"
}
],
"repeat": "",
"crontab": "",
"once": true,
"onceDelay": "0.5",
"topic": "",
"payload": "",
"payloadType": "date",
"x": 130,
"y": 100,
"wires": [
[
"c949a2be3e9208fa"
]
]
},
{
"id": "c949a2be3e9208fa",
"type": "change",
"z": "0262df3f88e799c6",
"g": "53d3b08cf818d2e0",
"name": "Mode = 0 (Manual)",
"rules": [
{
"t": "set",
"p": "payload",
"pt": "msg",
"to": "0",
"tot": "num"
}
],
"action": "",
"property": "",
"from": "",
"to": "",
"reg": false,
"x": 330,
"y": 100,
"wires": [
[
"44615a1601fcfb8a"
]
]
},
{
"id": "44615a1601fcfb8a",
"type": "victron-output-evcharger",
"z": "0262df3f88e799c6",
"g": "53d3b08cf818d2e0",
"service": "com.victronenergy.evcharger/40",
"path": "/Mode",
"serviceObj": {
"service": "com.victronenergy.evcharger/40",
"name": "EVCS",
"communityTag": "evcharger"
},
"pathObj": {
"path": "/Mode",
"type": "integer",
"name": "Charge mode (0=Manual, 1=Auto)",
"writable": true
},
"initial": "",
"name": "EVCS Mode",
"onlyChanges": false,
"outputs": 0,
"x": 570,
"y": 100,
"wires": []
},
{
"id": "020290bac7b2e065",
"type": "victron-input-gridmeter",
"z": "0262df3f88e799c6",
"g": "e49f4739e7a270e0",
"service": "com.victronenergy.grid/40",
"path": "/Ac/Power",
"serviceObj": {
"service": "com.victronenergy.grid/40",
"name": "HomeWizard Energy P1",
"communityTag": "gridmeter"
},
"pathObj": {
"path": "/Ac/Power",
"type": "float",
"name": "Power (W)"
},
"name": "",
"onlyChanges": false,
"roundValues": "no",
"rateLimit": 0,
"outputs": 1,
"conditionalMode": false,
"outputTrue": "true",
"outputFalse": "false",
"debounce": "2000",
"x": 180,
"y": 240,
"wires": [
[
"d3e99a0740e7c04b"
]
]
},
{
"id": "332dbf5c924a80a8",
"type": "victron-input-gridmeter",
"z": "0262df3f88e799c6",
"g": "e49f4739e7a270e0",
"service": "com.victronenergy.grid/40",
"path": "/Ac/L1/Voltage",
"serviceObj": {
"service": "com.victronenergy.grid/40",
"name": "HomeWizard Energy P1",
"communityTag": "gridmeter"
},
"pathObj": {
"path": "/Ac/L1/Voltage",
"type": "float",
"name": "L1 Voltage (V)"
},
"name": "",
"onlyChanges": false,
"roundValues": "no",
"rateLimit": 0,
"outputs": 1,
"conditionalMode": false,
"outputTrue": "true",
"outputFalse": "false",
"debounce": "2000",
"x": 190,
"y": 300,
"wires": [
[
"bd7320a3915994e3"
]
]
},
{
"id": "59946e4ad6cab677",
"type": "victron-input-gridmeter",
"z": "0262df3f88e799c6",
"g": "e49f4739e7a270e0",
"service": "com.victronenergy.grid/40",
"path": "/Ac/L2/Voltage",
"serviceObj": {
"service": "com.victronenergy.grid/40",
"name": "HomeWizard Energy P1",
"communityTag": "gridmeter"
},
"pathObj": {
"path": "/Ac/L2/Voltage",
"type": "float",
"name": "L2 Voltage (V)"
},
"name": "",
"onlyChanges": false,
"roundValues": "no",
"rateLimit": 0,
"outputs": 1,
"conditionalMode": false,
"outputTrue": "true",
"outputFalse": "false",
"debounce": "2000",
"x": 190,
"y": 360,
"wires": [
[
"6a032699471d513e"
]
]
},
{
"id": "e4c7b429127bb18c",
"type": "victron-input-gridmeter",
"z": "0262df3f88e799c6",
"g": "e49f4739e7a270e0",
"service": "com.victronenergy.grid/40",
"path": "/Ac/L3/Voltage",
"serviceObj": {
"service": "com.victronenergy.grid/40",
"name": "HomeWizard Energy P1",
"communityTag": "gridmeter"
},
"pathObj": {
"path": "/Ac/L3/Voltage",
"type": "float",
"name": "L3 Voltage (V)"
},
"name": "",
"onlyChanges": false,
"roundValues": "no",
"rateLimit": 0,
"outputs": 1,
"conditionalMode": false,
"outputTrue": "true",
"outputFalse": "false",
"debounce": "2000",
"x": 190,
"y": 420,
"wires": [
[
"5c737be3eec6f5d6"
]
]
},
{
"id": "d3e99a0740e7c04b",
"type": "change",
"z": "0262df3f88e799c6",
"g": "e49f4739e7a270e0",
"name": "topic=grid_power",
"rules": [
{
"t": "set",
"p": "topic",
"pt": "msg",
"to": "grid_power",
"tot": "str"
}
],
"action": "",
"property": "",
"from": "",
"to": "",
"reg": false,
"x": 470,
"y": 240,
"wires": [
[
"8050ccab6c42918c"
]
]
},
{
"id": "bd7320a3915994e3",
"type": "change",
"z": "0262df3f88e799c6",
"g": "e49f4739e7a270e0",
"name": "topic=v_L1",
"rules": [
{
"t": "set",
"p": "topic",
"pt": "msg",
"to": "v_L1",
"tot": "str"
}
],
"action": "",
"property": "",
"from": "",
"to": "",
"reg": false,
"x": 450,
"y": 300,
"wires": [
[
"8050ccab6c42918c"
]
]
},
{
"id": "6a032699471d513e",
"type": "change",
"z": "0262df3f88e799c6",
"g": "e49f4739e7a270e0",
"name": "topic=v_L2",
"rules": [
{
"t": "set",
"p": "topic",
"pt": "msg",
"to": "v_L2",
"tot": "str"
}
],
"action": "",
"property": "",
"from": "",
"to": "",
"reg": false,
"x": 450,
"y": 360,
"wires": [
[
"8050ccab6c42918c"
]
]
},
{
"id": "5c737be3eec6f5d6",
"type": "change",
"z": "0262df3f88e799c6",
"g": "e49f4739e7a270e0",
"name": "topic=v_L3",
"rules": [
{
"t": "set",
"p": "topic",
"pt": "msg",
"to": "v_L3",
"tot": "str"
}
],
"action": "",
"property": "",
"from": "",
"to": "",
"reg": false,
"x": 450,
"y": 420,
"wires": [
[
"8050ccab6c42918c"
]
]
},
{
"id": "e8313c4d38e6ccbb",
"type": "victron-input-evcharger",
"z": "0262df3f88e799c6",
"g": "e49f4739e7a270e0",
"service": "com.victronenergy.evcharger/40",
"path": "/Ac/Power",
"serviceObj": {
"service": "com.victronenergy.evcharger/40",
"name": "EVCS",
"communityTag": "evcharger"
},
"pathObj": {
"path": "/Ac/Power",
"type": "float",
"name": "Total power (W)"
},
"name": "EVCS /Ac/Power",
"onlyChanges": false,
"outputs": 1,
"conditionalMode": false,
"outputTrue": "",
"outputFalse": "",
"debounce": "",
"x": 120,
"y": 480,
"wires": [
[
"efa89831788ee131"
]
]
},
{
"id": "efa89831788ee131",
"type": "change",
"z": "0262df3f88e799c6",
"g": "e49f4739e7a270e0",
"name": "topic=evcs_power",
"rules": [
{
"t": "set",
"p": "topic",
"pt": "msg",
"to": "evcs_power",
"tot": "str"
}
],
"action": "",
"property": "",
"from": "",
"to": "",
"reg": false,
"x": 470,
"y": 480,
"wires": [
[
"8050ccab6c42918c"
]
]
},
{
"id": "9192ec79887859e3",
"type": "victron-input-evcharger",
"z": "0262df3f88e799c6",
"g": "e49f4739e7a270e0",
"service": "com.victronenergy.evcharger/40",
"path": "/Ac/L2/Power",
"serviceObj": {
"service": "com.victronenergy.evcharger/40",
"name": "EVCS",
"communityTag": "evcharger"
},
"pathObj": {
"path": "/Ac/L2/Power",
"type": "float",
"name": "L2 Power (W)"
},
"name": "EVCS L2 Power (phase detect)",
"onlyChanges": false,
"outputs": 1,
"conditionalMode": false,
"outputTrue": "",
"outputFalse": "",
"debounce": "",
"x": 170,
"y": 540,
"wires": [
[
"e3b85f2603d6e13d"
]
]
},
{
"id": "e3b85f2603d6e13d",
"type": "change",
"z": "0262df3f88e799c6",
"g": "e49f4739e7a270e0",
"name": "topic=evcs_L2_power",
"rules": [
{
"t": "set",
"p": "topic",
"pt": "msg",
"to": "evcs_L2_power",
"tot": "str"
}
],
"action": "",
"property": "",
"from": "",
"to": "",
"reg": false,
"x": 480,
"y": 540,
"wires": [
[
"8050ccab6c42918c"
]
]
},
{
"id": "8253e0136744d27b",
"type": "victron-input-evcharger",
"z": "0262df3f88e799c6",
"g": "e49f4739e7a270e0",
"service": "com.victronenergy.evcharger/40",
"path": "/Status",
"serviceObj": {
"service": "com.victronenergy.evcharger/40",
"name": "EVCS",
"communityTag": "evcharger"
},
"pathObj": {
"path": "/Status",
"type": "enum",
"name": "Status",
"enum": {
"0": "Disconnected",
"1": "Connected",
"2": "Charging",
"3": "Charged",
"4": "Waiting for sun",
"5": "Waiting for RFID",
"6": "Waiting for start",
"7": "Low SOC",
"8": "Ground test error",
"9": "Welded contacts test error",
"10": "CP input test error (shorted)",
"11": "Residual current detected",
"12": "Undervoltage detected",
"13": "Overvoltage detected",
"14": "Overheating detected",
"15": "Reserved",
"16": "Reserved",
"17": "Reserved",
"18": "Reserved",
"19": "Reserved",
"20": "Charging limit",
"21": "Start charging",
"22": "Switching to 3 phase",
"23": "Switching to 1 phase",
"24": "Stop charging"
}
},
"name": "EVCS /Status",
"onlyChanges": false,
"outputs": 1,
"conditionalMode": false,
"outputTrue": "",
"outputFalse": "",
"debounce": "",
"x": 110,
"y": 600,
"wires": [
[
"e7d67300c7a31008"
]
]
},
{
"id": "e7d67300c7a31008",
"type": "change",
"z": "0262df3f88e799c6",
"g": "e49f4739e7a270e0",
"name": "topic=evcs_status",
"rules": [
{
"t": "set",
"p": "topic",
"pt": "msg",
"to": "evcs_status",
"tot": "str"
}
],
"action": "",
"property": "",
"from": "",
"to": "",
"reg": false,
"x": 470,
"y": 600,
"wires": [
[
"8050ccab6c42918c"
]
]
},
{
"id": "3dd82bf8ceb2effd",
"type": "victron-input-evcharger",
"z": "0262df3f88e799c6",
"g": "e49f4739e7a270e0",
"service": "com.victronenergy.evcharger/40",
"path": "/Mode",
"serviceObj": {
"service": "com.victronenergy.evcharger/40",
"name": "EVCS",
"communityTag": "evcharger"
},
"pathObj": {
"path": "/Mode",
"type": "integer",
"name": "Mode (0=Manual 1=Auto 2=Scheduled) — toggle detect"
},
"name": "EVCS /Mode (toggle detect)",
"onlyChanges": false,
"outputs": 1,
"conditionalMode": false,
"outputTrue": "",
"outputFalse": "",
"debounce": "",
"x": 160,
"y": 660,
"wires": [
[
"fb738d03688a3f6f"
]
]
},
{
"id": "fb738d03688a3f6f",
"type": "change",
"z": "0262df3f88e799c6",
"g": "e49f4739e7a270e0",
"name": "topic=evcs_mode",
"rules": [
{
"t": "set",
"p": "topic",
"pt": "msg",
"to": "evcs_mode",
"tot": "str"
}
],
"action": "",
"property": "",
"from": "",
"to": "",
"reg": false,
"x": 470,
"y": 660,
"wires": [
[
"8050ccab6c42918c"
]
]
},
{
"id": "ef9760b5c32bbe71",
"type": "victron-input-evcharger",
"z": "0262df3f88e799c6",
"g": "e49f4739e7a270e0",
"service": "com.victronenergy.evcharger/40",
"path": "/MaxCurrent",
"serviceObj": {
"service": "com.victronenergy.evcharger/40",
"name": "EVCS",
"communityTag": "evcharger"
},
"pathObj": {
"path": "/MaxCurrent",
"type": "float",
"name": "Maximum charge current (A)"
},
"name": "EVCS /MaxCurrent",
"onlyChanges": false,
"outputs": 1,
"conditionalMode": false,
"outputTrue": "",
"outputFalse": "",
"debounce": "",
"x": 130,
"y": 720,
"wires": [
[
"0e8adf658ce77204"
]
]
},
{
"id": "0e8adf658ce77204",
"type": "change",
"z": "0262df3f88e799c6",
"g": "e49f4739e7a270e0",
"name": "topic=evcs_max_current",
"rules": [
{
"t": "set",
"p": "topic",
"pt": "msg",
"to": "evcs_max_current",
"tot": "str"
}
],
"action": "",
"property": "",
"from": "",
"to": "",
"reg": false,
"x": 490,
"y": 720,
"wires": [
[
"8050ccab6c42918c"
]
]
},
{
"id": "8050ccab6c42918c",
"type": "join",
"z": "0262df3f88e799c6",
"g": "e49f4739e7a270e0",
"name": "Merge inputs",
"mode": "custom",
"build": "object",
"property": "payload",
"propertyType": "msg",
"key": "topic",
"joiner": "\\n",
"joinerType": "str",
"accumulate": true,
"timeout": "",
"count": "7",
"reduceRight": false,
"x": 800,
"y": 480,
"wires": [
[
"0d44efc40a213ad5"
]
]
},
{
"id": "0d44efc40a213ad5",
"type": "function",
"z": "0262df3f88e799c6",
"g": "f9c1da2ca51b0d7b",
"name": "Solar Surplus Control",
"func": "// ── Constants — edit via tab Environment variables ──────────\n//\n// How to reach them:\n// 1. Double-click the \"EVCS Solar Surplus\" tab name\n// 2. Click the list icon (≡) in the top-right of the dialog\n// 3. Edit values under \"Environment Variables\"\n// 4. Click Done → Deploy\n//\nconst BUFFER_W = Number(env.get('EVCS_BUFFER_W')) || 200;\nconst MIN_A = 6; // hardware minimum, fixed (reg 5062)\nconst MAX_A = Number(env.get('EVCS_MAX_A')) || 16; // fallback if /MaxCurrent unavailable\nconst STOP_DELAY_MS = (Number(env.get('EVCS_STOP_DELAY_S')) || 30) * 1000;\nconst START_DELAY_MS = (Number(env.get('EVCS_START_DELAY_S')) || 60) * 1000;\nconst PHASE_DETECT_W = Number(env.get('EVCS_PHASE_DETECT_W')) || 50; // W above this → 3-phase\n\n// ── Inputs from join ────────────────────────────────────────\nconst p = msg.payload;\nconst needed = ['grid_power','v_L1','v_L2','v_L3','evcs_power','evcs_L2_power','evcs_status'];\nfor (const k of needed) { if (p[k] === undefined) return null; }\n\nconst gridW = p.grid_power;\nconst vL1 = p.v_L1;\nconst vL2 = p.v_L2;\nconst vL3 = p.v_L3;\nconst evcsW = p.evcs_power !== null ? p.evcs_power : 0;\nconst evcsL2W = p.evcs_L2_power !== null ? p.evcs_L2_power : 0;\nconst status = p.evcs_status;\nconst maxCurrent = (p.evcs_max_current !== null && p.evcs_max_current !== undefined)\n ? p.evcs_max_current : MAX_A;\n\n// ── Mode toggle: detect display button press ─────────────────\n// Node-RED always owns Mode=0 (Manual). The EVCS display button\n// cycles: Manual(0) → Auto(1) → Scheduled(2) → Manual(0) → ...\n// After Node-RED reverts to 0, the next press may land on 1 OR 2\n// depending on where the internal cycle counter is.\n// Fix: treat ANY non-zero Mode value (not just 1) as the toggle signal.\nconst modeReading = Number(p.evcs_mode !== null && p.evcs_mode !== undefined ? p.evcs_mode : 0);\nconst prevMode = context.get('prevMode') !== undefined ? context.get('prevMode') : 0;\nlet override = flow.get('evcs_override') !== undefined ? flow.get('evcs_override') : false;\nlet modeRevert = false;\n\nif (modeReading !== 0 && prevMode === 0) {\n // Display switched away from Manual — treat as toggle regardless of which mode\n override = !override;\n flow.set('evcs_override', override);\n modeRevert = true;\n node.warn('EVCS mode button pressed — Mode=' + modeReading +\n ' (' + (modeReading === 1 ? 'Auto' : 'Scheduled') + ')' +\n ' → override ' + (override ? 'ON ⚡' : 'OFF ☀'));\n}\ncontext.set('prevMode', modeReading);\n\n// ── Grid charge override ─────────────────────────────────────\nif (override) {\n const debugOvr = {\n mode: '⚡ GRID OVERRIDE — charging at ' + maxCurrent.toFixed(1) + ' A',\n maxCurrent: maxCurrent.toFixed(1) + ' A',\n status: status,\n tip: 'Press mode button on EVCS display, or click ☀ Resume solar inject node to cancel',\n };\n return [\n { payload: maxCurrent },\n { payload: 1 },\n { payload: debugOvr },\n modeRevert ? { payload: 0 } : null,\n ];\n}\n\n// ── No car / fully charged: reset state and exit ────────────\nif (status === 0 || status === 3) {\n context.set('isStopped', true);\n context.set('stopPendingAt', null);\n context.set('startPendingAt', null);\n return [null, null, null, modeRevert ? { payload: 0 } : null];\n}\n\n// ── Phase detection from EVCS L2 power ──────────────────────\nconst phases = (evcsL2W > PHASE_DETECT_W) ? 3 : 1;\nconst voltageSum = (phases === 3) ? (vL1 + vL2 + vL3) : vL1;\n\n// ── Surplus calculation ─────────────────────────────────────\nconst surplusW = -(gridW) + evcsW - BUFFER_W;\nlet availableA = surplusW / voltageSum;\navailableA = Math.max(0, Math.min(maxCurrent, availableA));\n\n// ── Hysteresis state machine ────────────────────────────────\nlet isStopped = context.get('isStopped') !== undefined ? context.get('isStopped') : true;\nlet stopPendingAt = context.get('stopPendingAt') !== undefined ? context.get('stopPendingAt') : null;\nlet startPendingAt = context.get('startPendingAt') !== undefined ? context.get('startPendingAt') : null;\nconst now = Date.now();\n\nlet newStartStop = isStopped ? 0 : 1;\nlet newCurrent = MIN_A;\n\nif (!isStopped) {\n if (availableA < MIN_A) {\n if (stopPendingAt === null) { stopPendingAt = now; }\n else if (now - stopPendingAt >= STOP_DELAY_MS) {\n isStopped = true; newStartStop = 0; stopPendingAt = null;\n }\n newCurrent = MIN_A;\n } else {\n stopPendingAt = null;\n newCurrent = availableA;\n }\n} else {\n if (availableA >= MIN_A) {\n if (startPendingAt === null) { startPendingAt = now; }\n else if (now - startPendingAt >= START_DELAY_MS) {\n isStopped = false; newStartStop = 1; newCurrent = availableA; startPendingAt = null;\n }\n } else {\n startPendingAt = null;\n }\n}\n\ncontext.set('isStopped', isStopped);\ncontext.set('stopPendingAt', stopPendingAt);\ncontext.set('startPendingAt', startPendingAt);\n\nconst debug = {\n mode: '☀ SOLAR SURPLUS',\n gridW: gridW.toFixed(0) + ' W',\n evcsW: evcsW.toFixed(0) + ' W',\n evcsL2W: evcsL2W.toFixed(0) + ' W',\n phases: phases + 'P',\n voltageSum: voltageSum.toFixed(1) + ' V',\n surplusW: surplusW.toFixed(0) + ' W',\n availableA: availableA.toFixed(2) + ' A',\n newCurrent: newCurrent.toFixed(2) + ' A',\n newStartStop: newStartStop,\n isStopped: isStopped,\n maxCurrent: maxCurrent.toFixed(1) + ' A',\n stopCountdown: stopPendingAt ? Math.round((STOP_DELAY_MS - (now - stopPendingAt)) / 1000) + 's' : null,\n startCountdown: startPendingAt ? Math.round((START_DELAY_MS - (now - startPendingAt)) / 1000) + 's' : null,\n};\n\nreturn [\n { payload: newCurrent },\n { payload: newStartStop },\n { payload: debug },\n modeRevert ? { payload: 0 } : null,\n];",
"outputs": 4,
"timeout": "",
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1080,
"y": 480,
"wires": [
[
"82cae138390cd13d"
],
[
"96146a4497e7bcd7"
],
[
"f0321f629373f7f5"
],
[
"3ff86adc0770c5dd"
]
]
},
{
"id": "82cae138390cd13d",
"type": "rbe",
"z": "0262df3f88e799c6",
"g": "2c122f5c6e60ae15",
"name": "",
"func": "rbe",
"gap": "0.5",
"start": "",
"inout": "out",
"septopics": false,
"property": "payload",
"topi": "topic",
"x": 1490,
"y": 360,
"wires": [
[
"394575d72837dea4"
]
]
},
{
"id": "394575d72837dea4",
"type": "victron-output-evcharger",
"z": "0262df3f88e799c6",
"g": "2c122f5c6e60ae15",
"service": "com.victronenergy.evcharger/40",
"path": "/SetCurrent",
"serviceObj": {
"service": "com.victronenergy.evcharger/40",
"name": "EVCS",
"communityTag": "evcharger"
},
"pathObj": {
"path": "/SetCurrent",
"type": "float",
"name": "Set charge current (manual mode) (A)",
"writable": true
},
"initial": "",
"name": "SetCurrent",
"onlyChanges": false,
"outputs": 0,
"x": 1670,
"y": 360,
"wires": []
},
{
"id": "96146a4497e7bcd7",
"type": "rbe",
"z": "0262df3f88e799c6",
"g": "2c122f5c6e60ae15",
"name": "",
"func": "rbe",
"gap": "",
"start": "",
"inout": "out",
"septopics": false,
"property": "payload",
"topi": "topic",
"x": 1490,
"y": 420,
"wires": [
[
"65d767d1d7903f78"
]
]
},
{
"id": "65d767d1d7903f78",
"type": "victron-output-evcharger",
"z": "0262df3f88e799c6",
"g": "2c122f5c6e60ae15",
"service": "com.victronenergy.evcharger/40",
"path": "/StartStop",
"serviceObj": {
"service": "com.victronenergy.evcharger/40",
"name": "EVCS",
"communityTag": "evcharger"
},
"pathObj": {
"path": "/StartStop",
"type": "integer",
"name": "Start/stop charging (manual mode)",
"writable": true
},
"initial": "",
"name": "StartStop",
"onlyChanges": false,
"outputs": 0,
"x": 1660,
"y": 420,
"wires": []
},
{
"id": "3ff86adc0770c5dd",
"type": "victron-output-evcharger",
"z": "0262df3f88e799c6",
"g": "2c122f5c6e60ae15",
"service": "com.victronenergy.evcharger/40",
"path": "/Mode",
"serviceObj": {
"service": "com.victronenergy.evcharger/40",
"name": "EVCS",
"communityTag": "evcharger"
},
"pathObj": {
"path": "/Mode",
"type": "integer",
"name": "Charge mode (0=Manual, 1=Auto)",
"writable": true
},
"initial": "",
"name": "Mode revert (no rbe)",
"onlyChanges": false,
"outputs": 0,
"x": 1490,
"y": 500,
"wires": []
},
{
"id": "f0321f629373f7f5",
"type": "debug",
"z": "0262df3f88e799c6",
"g": "d6a4424e7a78e5cb",
"name": "Control state",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "payload",
"targetType": "msg",
"statusVal": "",
"statusType": "auto",
"x": 1450,
"y": 620,
"wires": []
},
{
"id": "8099d31cac5e8ebf",
"type": "inject",
"z": "0262df3f88e799c6",
"g": "015de6af7d2ac5db",
"name": "⚡ Force grid charge",
"props": [
{
"p": "payload"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"topic": "",
"payload": "",
"payloadType": "date",
"x": 150,
"y": 860,
"wires": [
[
"0b3b753ba9aab4c1"
]
]
},
{
"id": "0b3b753ba9aab4c1",
"type": "function",
"z": "0262df3f88e799c6",
"g": "015de6af7d2ac5db",
"name": "Set override = true",
"func": "flow.set('evcs_override', true);\nreturn null;",
"outputs": 1,
"timeout": "",
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 390,
"y": 860,
"wires": [
[]
]
},
{
"id": "ddec341471deedd6",
"type": "inject",
"z": "0262df3f88e799c6",
"g": "015de6af7d2ac5db",
"name": "☀ Resume solar",
"props": [
{
"p": "payload"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": "",
"topic": "",
"payload": "",
"payloadType": "date",
"x": 140,
"y": 920,
"wires": [
[
"ddce531852010ae4"
]
]
},
{
"id": "ddce531852010ae4",
"type": "function",
"z": "0262df3f88e799c6",
"g": "015de6af7d2ac5db",
"name": "Set override = false",
"func": "flow.set('evcs_override', false);\nreturn null;",
"outputs": 1,
"timeout": "",
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 390,
"y": 920,
"wires": [
[]
]
},
{
"id": "dc91b20cfb46618d",
"type": "global-config",
"env": [],
"modules": {
"@victronenergy/node-red-contrib-victron": "1.6.63"
}
}
]

Victron EVCS Solar Surplus Charging — Setup Guide

This Node-RED flow can be used to control a Victron EV Charging Station (EVCS) from solar surplus using Node-RED on Venus OS. The flow keeps the EVCS in Manual mode and adjusts charge current continuously based on grid meter readings, keeping a configurable buffer flowing to the grid to guarantee no grid power is used for charging.

An override allows forcing full-speed charging from the grid at any time, triggered either by the mode button on the EVCS display or by inject buttons in the Node-RED GUI.



Prerequisites

  • Victron Cerbo GX (or other GX device) with Venus OS Large installed
  • Victron EVCS (EV Charging Station NS or with display). The screen version is preferred, as then the switching to full grid power is easier.
  • Grid meter visible to the GX device (tested with HomeWizard Energy P1 via com.victronenergy.grid)
  • Node-RED enabled on the GX device (Settings → Venus OS Large → Node-RED)
  • node-red-contrib-victron installed (included with Venus OS Large)

Step 1 — One-time EVCS Modbus TCP register changes

These registers need to be written once and survive reboots. Use the a Modbus TCP tool to write them to the EVCS at its IP address, port 502.

Registers to change

Register Name Value Note
5009 Charge mode 1 (Auto) Node-RED overwrites this to 0 (Manual) on every deploy
5055 Manual mode phase 0 0 = 3-phase in the multiple relay charging context. Already correct
5056 Auto mode phase 2 2 = auto 1P/3P switching. Used if EVCS falls back to auto
5057 Power for 3-phase 3500 W Phase switching threshold (auto and manual)
5058 Power for single phase 4200 W Phase switching threshold
5059 Phase switching timeout 60 s Time between phase switches
5062 Min current 6 A Hardware minimum, fixed
5068 Backup battery emulation 1 If you don't have a battery, the EVCS needs this to auto charge
5083 Available sun power threshold for start (Battery powered) 1380 W Safety net: if Node-RED is down and EVCS reverts to auto mode, it won't start below 6 A (6 A × 230 V = 1380 W) of solar
5100 Multiple relay charging 1 (enabled) Required for 3P switching in manual mode
5101 Multiple relay charging relays switching delay 60 s Already set correctly
5102 Allow battery/grid power for auto mode 0 (disabled) Prevents grid draw if EVCS falls back to auto
5103 Battery/grid power timeout 5 min Auto mode timeout, irrelevant in manual mode

Step 2 — Import the Node-RED flow

  1. Open Node-RED on the Cerbo: https://venus.local:1881
  2. Hamburger menu (≡) → Import
  3. Upload or paste flows.json
  4. Click Import
  5. Click Deploy

On deploy, the initialisation group fires 0.5 s after startup and writes Mode = 0 (Manual) to the EVCS via D-Bus. Verify with a Modbus TCP tool: register 5009 should read 0.


Step 3 — Verify device selection

After import, open each Victron input/output node and confirm the correct device is selected. The flow is pre-configured for the devices below; if your service instances differ, update them.

Node Device D-Bus service
Grid /Ac/Power, /Ac/L1-L3/Voltage HomeWizard Energy P1 com.victronenergy.grid/40
EVCS /Ac/Power, /Ac/L2/Power, /Status, /Mode, /MaxCurrent EVCS com.victronenergy.evcharger/40
EVCS output /Mode, /SetCurrent, /StartStop EVCS com.victronenergy.evcharger/40

Step 4 — Tune the constants

Constants are stored as environment variables on the flow tab. To edit:

  1. Double-click the EVCS Solar Surplus tab name
  2. Click the list icon (≡) in the top-right of the dialog
  3. Edit values under Environment Variables
  4. Click DoneDeploy to activate the new settings
Variable Default Unit Description
EVCS_BUFFER_W 200 W Solar surplus kept flowing to grid. Raise if you still see grid draw during charging
EVCS_MAX_A 16 A Fallback max current if /MaxCurrent is unavailable. Normally /MaxCurrent is read live from the EVCS
EVCS_STOP_DELAY_S 30 s How long surplus must be below 6 A before charging stops
EVCS_START_DELAY_S 60 s How long surplus must be above 6 A before charging starts
EVCS_PHASE_DETECT_W 50 W EVCS L2 power above this threshold → 3-phase mode detected

How it works

Surplus calculation

On every D-Bus update from the grid meter and EVCS, the flow calculates:

surplus_W = -(grid_power_W) + evcs_power_W - BUFFER_W
  • grid_power_W is negative when exporting → negating gives a positive surplus
  • Adding back evcs_power_W avoids a feedback loop: the EVCS draw is already reflected in the grid reading, so without this correction the flow would under-estimate available solar
  • BUFFER_W is subtracted to guarantee the grid export never drops to zero

The available charge current is then:

available_A = surplus_W / voltage_sum

Where voltage_sum is V_L1 (1-phase) or V_L1 + V_L2 + V_L3 (3-phase), read live from the grid meter to get a more correct calculation, instead of assuming a fixed 230V.

Phase detection

3-phase mode is detected from the EVCS L2 power reading:

phases = (evcs_L2_power > EVCS_PHASE_DETECT_W) ? 3 : 1

This is more reliable than a static register read because it reflects what the EVCS is actually doing right now. The EVCS handles 1P/3P switching autonomously based on its own power thresholds (registers 5057 and 5058).

Hysteresis

To prevent rapid start/stop cycling near the 6 A threshold:

  • Stop: surplus must be below 6 A for EVCS_STOP_DELAY_S seconds continuously before StartStop = 0 is sent. While counting down, current is held at 6 A.
  • Start: surplus must be above 6 A for EVCS_START_DELAY_S seconds continuously before StartStop = 1 is sent.

Function code

The controlling code looks like this:

// ── Constants — edit via tab Environment variables ──────────
//
//   How to reach them:
//     1. Double-click the "EVCS Solar Surplus" tab name
//     2. Click the list icon (≡) in the top-right of the dialog
//     3. Edit values under "Environment Variables"
//     4. Click Done → Deploy
//
const BUFFER_W       = Number(env.get('EVCS_BUFFER_W'))       || 200;
const MIN_A          = 6;                                              // hardware minimum, fixed (reg 5062)
const MAX_A          = Number(env.get('EVCS_MAX_A'))          || 16;  // fallback if /MaxCurrent unavailable
const STOP_DELAY_MS  = (Number(env.get('EVCS_STOP_DELAY_S'))  || 30) * 1000;
const START_DELAY_MS = (Number(env.get('EVCS_START_DELAY_S')) || 60) * 1000;
const PHASE_DETECT_W = Number(env.get('EVCS_PHASE_DETECT_W')) || 50;  // W above this → 3-phase

// ── Inputs from join ────────────────────────────────────────
const p = msg.payload;
const needed = ['grid_power','v_L1','v_L2','v_L3','evcs_power','evcs_L2_power','evcs_status'];
for (const k of needed) { if (p[k] === undefined) return null; }

const gridW      = p.grid_power;
const vL1        = p.v_L1;
const vL2        = p.v_L2;
const vL3        = p.v_L3;
const evcsW      = p.evcs_power      !== null ? p.evcs_power      : 0;
const evcsL2W    = p.evcs_L2_power   !== null ? p.evcs_L2_power   : 0;
const status     = p.evcs_status;
const maxCurrent = (p.evcs_max_current !== null && p.evcs_max_current !== undefined)
                   ? p.evcs_max_current : MAX_A;

// ── Mode toggle: detect display button press ─────────────────
// Node-RED always owns Mode=0 (Manual). The EVCS display button
// cycles: Manual(0) → Auto(1) → Scheduled(2) → Manual(0) → ...
// After Node-RED reverts to 0, the next press may land on 1 OR 2
// depending on where the internal cycle counter is.
// Fix: treat ANY non-zero Mode value (not just 1) as the toggle signal.
const modeReading = Number(p.evcs_mode !== null && p.evcs_mode !== undefined ? p.evcs_mode : 0);
const prevMode    = context.get('prevMode') !== undefined ? context.get('prevMode') : 0;
let   override    = flow.get('evcs_override') !== undefined ? flow.get('evcs_override') : false;
let   modeRevert  = false;

if (modeReading !== 0 && prevMode === 0) {
    // Display switched away from Manual — treat as toggle regardless of which mode
    override  = !override;
    flow.set('evcs_override', override);
    modeRevert = true;
    node.warn('EVCS mode button pressed — Mode=' + modeReading +
              ' (' + (modeReading === 1 ? 'Auto' : 'Scheduled') + ')' +
              ' → override ' + (override ? 'ON ⚡' : 'OFF ☀'));
}
context.set('prevMode', modeReading);

// ── Grid charge override ─────────────────────────────────────
if (override) {
    const debugOvr = {
        mode:       '⚡ GRID OVERRIDE — charging at ' + maxCurrent.toFixed(1) + ' A',
        maxCurrent: maxCurrent.toFixed(1) + ' A',
        status:     status,
        tip:        'Press mode button on EVCS display, or click ☀ Resume solar inject node to cancel',
    };
    return [
        { payload: maxCurrent },
        { payload: 1 },
        { payload: debugOvr },
        modeRevert ? { payload: 0 } : null,
    ];
}

// ── No car / fully charged: reset state and exit ────────────
if (status === 0 || status === 3) {
    context.set('isStopped',      true);
    context.set('stopPendingAt',  null);
    context.set('startPendingAt', null);
    return [null, null, null, modeRevert ? { payload: 0 } : null];
}

// ── Phase detection from EVCS L2 power ──────────────────────
const phases     = (evcsL2W > PHASE_DETECT_W) ? 3 : 1;
const voltageSum = (phases === 3) ? (vL1 + vL2 + vL3) : vL1;

// ── Surplus calculation ─────────────────────────────────────
const surplusW = -(gridW) + evcsW - BUFFER_W;
let availableA = surplusW / voltageSum;
availableA     = Math.max(0, Math.min(maxCurrent, availableA));

// ── Hysteresis state machine ────────────────────────────────
let isStopped      = context.get('isStopped')      !== undefined ? context.get('isStopped')      : true;
let stopPendingAt  = context.get('stopPendingAt')  !== undefined ? context.get('stopPendingAt')  : null;
let startPendingAt = context.get('startPendingAt') !== undefined ? context.get('startPendingAt') : null;
const now          = Date.now();

let newStartStop = isStopped ? 0 : 1;
let newCurrent   = MIN_A;

if (!isStopped) {
    if (availableA < MIN_A) {
        if (stopPendingAt === null) { stopPendingAt = now; }
        else if (now - stopPendingAt >= STOP_DELAY_MS) {
            isStopped = true; newStartStop = 0; stopPendingAt = null;
        }
        newCurrent = MIN_A;
    } else {
        stopPendingAt = null;
        newCurrent    = availableA;
    }
} else {
    if (availableA >= MIN_A) {
        if (startPendingAt === null) { startPendingAt = now; }
        else if (now - startPendingAt >= START_DELAY_MS) {
            isStopped = false; newStartStop = 1; newCurrent = availableA; startPendingAt = null;
        }
    } else {
        startPendingAt = null;
    }
}

context.set('isStopped',      isStopped);
context.set('stopPendingAt',  stopPendingAt);
context.set('startPendingAt', startPendingAt);

const debug = {
    mode:           '☀ SOLAR SURPLUS',
    gridW:          gridW.toFixed(0) + ' W',
    evcsW:          evcsW.toFixed(0) + ' W',
    evcsL2W:        evcsL2W.toFixed(0) + ' W',
    phases:         phases + 'P',
    voltageSum:     voltageSum.toFixed(1) + ' V',
    surplusW:       surplusW.toFixed(0) + ' W',
    availableA:     availableA.toFixed(2) + ' A',
    newCurrent:     newCurrent.toFixed(2) + ' A',
    newStartStop:   newStartStop,
    isStopped:      isStopped,
    maxCurrent:     maxCurrent.toFixed(1) + ' A',
    stopCountdown:  stopPendingAt  ? Math.round((STOP_DELAY_MS  - (now - stopPendingAt))  / 1000) + 's' : null,
    startCountdown: startPendingAt ? Math.round((START_DELAY_MS - (now - startPendingAt)) / 1000) + 's' : null,
};

return [
    { payload: newCurrent   },
    { payload: newStartStop },
    { payload: debug        },
    modeRevert ? { payload: 0 } : null,
];

Grid charge override

When you need to charge from the grid regardless of solar surplus, the flow supports a toggle override. The override charges at the installer-configured maximum current (read live from /MaxCurrent, register 5017), always in 3-phase mode since 5055 = 0.

There are two ways to activate it:

Mode button on the EVCS display: The flow monitors /Mode continuously. Node-RED always owns Mode = 0 (Manual), so every time the display switches away from Manual — to either Auto (1) or Scheduled (2) — the flow treats this as a toggle signal, flips the override state, and immediately writes Mode = 0 back.

The EVCS display button cycles internally: Manual → Auto → Scheduled → Manual. After Node-RED reverts to Manual, the button counter continues from where it was, so the next press may land on Auto or Scheduled depending on the cycle position. The flow handles both correctly.

The display briefly shows "Auto" or "Scheduled" for about a second before reverting — this is expected behaviour.

  • 1st press → override on → charges at MaxCurrent from grid
  • 2nd press → override off → returns to solar surplus control

Each button press is logged in the Node-RED debug sidebar as a warning:

EVCS mode button pressed — Mode=1 (Auto) → override ON ⚡
EVCS mode button pressed — Mode=2 (Scheduled) → override OFF ☀

Inject buttons in Node-RED: The orange-bordered group at the bottom of the canvas contains two clickable buttons:

  • ⚡ Force grid charge → activates the override
  • ☀ Resume solar → deactivates the override

Both the display button and the inject buttons share the same override state, stored in flow context (flow.evcs_override). Either method can cancel what the other started.

Outputs

D-Bus path Written when Value
/Mode Once on deploy; also immediately on display toggle 0 (Manual)
/SetCurrent On change ≥ 0.5 A Calculated amps (solar mode) or MaxCurrent (override mode)
/StartStop On change 1 if charging, 0 if stopped

Monitoring

Open the debug sidebar in Node-RED while the car is charging. The Control state debug node outputs on every control cycle.

Solar surplus mode:

{
  "mode":          "☀ SOLAR SURPLUS",
  "gridW":         "-1850 W",
  "evcsW":         "2600 W",
  "evcsL2W":       "870 W",
  "phases":        "3P",
  "voltageSum":    "706.2 V",
  "surplusW":      "2250 W",
  "availableA":    "11.33 A",
  "newCurrent":    "11.33 A",
  "newStartStop":  1,
  "isStopped":     false,
  "maxCurrent":    "16.0 A",
  "stopCountdown": null,
  "startCountdown": null
}

Grid charge override mode:

{
  "mode":       "⚡ GRID OVERRIDE — charging at 16.0 A",
  "maxCurrent": "16.0 A",
  "status":     2,
  "tip":        "Press mode button on EVCS display, or click ☀ Resume solar inject node to cancel"
}

Negative gridW = exporting (good). surplusW after subtracting the buffer should be positive when charging. phases confirms whether 1P or 3P was detected.


Troubleshooting

Symptom Likely cause Fix
Flow produces no debug output Join waiting for initial 7 topics Check all core input nodes are connected; open each and verify the device is selected
EVCS still draws from grid EVCS_BUFFER_W too low, or surplus spikes during phase switch Raise EVCS_BUFFER_W to 300–400 W
Charging never starts Surplus consistently below 6 A × voltage Wait for better solar conditions; check surplusW in debug
Rapid start/stop cycling Hysteresis delays too short Raise EVCS_STOP_DELAY_S and EVCS_START_DELAY_S
Phase always shows 1P EVCS not switching to 3P Check EVCS_PHASE_DETECT_W; lower if EVCS L2 power is just above 50 W
Mode button only works one way EVCS button cycles Manual→Auto→Scheduled; 2nd press may land on Scheduled (2) not Auto (1) v6c fixes this — any non-Manual mode triggers the toggle
Mode reverts to Auto permanently Node-RED restarted without deploy Redeploy; or set 5083 to 1380 W as safety net for auto mode fallback
availableA looks wrong Grid meter not updating Check debounce setting (2000 ms) on gridmeter nodes

Reference — key EVCS status codes (register 5015 / D-Bus /Status)

Value Meaning
0 Disconnected
1 Connected
2 Charging
3 Charged
4 Waiting for sun
20 Charging limit
21 Start charging
22 Switching to 3 phase
23 Switching to 1 phase
24 Stop charging
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment