-
Notifications
You must be signed in to change notification settings - Fork 32
EV charge monitoring
nygma2004 edited this page Mar 12, 2023
·
3 revisions
This is a longer work in progress project where I am monitoring the charging of my EV with Node-Red:
- My EV has a single phase AC charger and I monitor the electricity usage using an Eastron SDM120 energy monitor
- I built a ESP8266 project to convert the Modbus data to MQTT which is processed in Node-Red: https://github.com/nygma2004/sdm1202mqtt
- Flow detects when the charging starts and stops (by monitoring the charge current)
- Calculates the charge time and the used kWh energy
- Accumulates the data for daily statistics
- Stores data in Influx DB bucket
I share the various videos on this step below:
A video on the PCB and ESP project to build a MQTT gateway for a single phase energy meter.
This is not related to Node-Red, but in case you are interested in the physical build.
This is a longer format video where I recorded the process when the entire logic started.
Fixing earlier mistakes and refining the flow.
Adding daily statistics, so I can see how long the car was charging daily, and the electricity consumption for that day.
And there is the published flow as explained in the videos above:
[
{
"id": "a05fd98639604605",
"type": "tab",
"label": "EV",
"disabled": false,
"info": "",
"env": []
},
{
"id": "48b964b93c7a3bf3",
"type": "comment",
"z": "a05fd98639604605",
"name": "Monitor energy consumption",
"info": "",
"x": 140,
"y": 40,
"wires": []
},
{
"id": "95ab49ab7f68eb35",
"type": "mqtt in",
"z": "a05fd98639604605",
"name": "",
"topic": "carcharger/data",
"qos": "0",
"datatype": "auto-detect",
"broker": "cea5258a.b34038",
"nl": false,
"rap": true,
"rh": 0,
"inputs": 0,
"x": 120,
"y": 100,
"wires": [
[
"ff074d005878a777",
"07a85e1da2ae5051",
"da684f4a493c7449",
"d5695bc1e633148f"
]
]
},
{
"id": "ff074d005878a777",
"type": "rbe",
"z": "a05fd98639604605",
"name": "Changes in Power",
"func": "rbe",
"gap": "",
"start": "",
"inout": "out",
"septopics": false,
"property": "payload",
"topi": "topic",
"x": 450,
"y": 100,
"wires": [
[
"94ae1a728c4b7abb"
]
]
},
{
"id": "94ae1a728c4b7abb",
"type": "delay",
"z": "a05fd98639604605",
"name": "",
"pauseType": "rate",
"timeout": "5",
"timeoutUnits": "seconds",
"rate": "1",
"nbRateUnits": "1",
"rateUnits": "minute",
"randomFirst": "1",
"randomLast": "5",
"randomUnits": "seconds",
"drop": true,
"allowrate": false,
"outputs": 1,
"x": 670,
"y": 100,
"wires": [
[
"b0a691f37a923d80"
]
]
},
{
"id": "b0a691f37a923d80",
"type": "function",
"z": "a05fd98639604605",
"name": "Convert to Influx",
"func": "\nlet record = {\n \"measurement\": \"sensors\",\n \"tags\": {\n \"device\": \"carcharger\"\n },\n \"fields\": {\n \"voltage\": msg.payload.voltage,\n \"current\": msg.payload.current,\n \"activepower\": msg.payload.activepower,\n \"powerfactor\": msg.payload.powerfactor,\n \"frequency\": msg.payload.frequency,\n \"totalactiveenergy\": msg.payload.totalactiveenergy\n }\n};\n\n\nmsg.payload = [record];\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 880,
"y": 100,
"wires": [
[
"b452211c52f94466",
"7ef13bf3d29cf4c0"
]
]
},
{
"id": "b452211c52f94466",
"type": "link out",
"z": "a05fd98639604605",
"name": "",
"mode": "link",
"links": [
"0d3a8155db4e5123",
"783d71afcd0cb582"
],
"x": 1035,
"y": 100,
"wires": []
},
{
"id": "5ec1a53397d12493",
"type": "comment",
"z": "a05fd98639604605",
"name": "Log data to Influx",
"info": "",
"x": 440,
"y": 40,
"wires": []
},
{
"id": "07a85e1da2ae5051",
"type": "function",
"z": "a05fd98639604605",
"name": "Monitor charging state",
"func": "let powerthreshold = 10;\n\nfunction padTo2Digits(num) {\n return num.toString().padStart(2, '0');\n}\n\nfunction convertMsToTime(milliseconds) {\n let seconds = Math.floor(milliseconds / 1000);\n let minutes = Math.floor(seconds / 60);\n let hours = Math.floor(minutes / 60);\n\n seconds = seconds % 60;\n minutes = minutes % 60;\n\n // ποΈ If you don't want to roll hours over, e.g. 24 to 00\n // ποΈ comment (or remove) the line below\n // commenting next line gets you `24:00:00` instead of `00:00:00`\n // or `36:15:31` instead of `12:15:31`, etc.\n // hours = hours % 24;\n\n return padTo2Digits(hours)+\":\"+padTo2Digits(minutes)+\":\"+padTo2Digits(seconds);\n}\n\nlet lastenergy = context.get(\"lastenergy\") || 0;\nif (msg.payload.totalactiveenergy===-1) {\n msg.payload.totalactiveenergy = lastenergy;\n} else {\n context.set(\"lastenergy\", msg.payload.totalactiveenergy);\n}\n\nlet chargestate = flow.get(\"chargestate\");\nif (chargestate===undefined) {\n chargestate = { \"charging\": false};\n flow.set(\"chargestate\", chargestate);\n}\n\nif ((!chargestate.charging) && (msg.payload.activepower >= powerthreshold)) {\n // charging starts\n chargestate = {};\n chargestate.charging = true;\n chargestate.starttime = new Date().getTime();\n chargestate.startenergy = msg.payload.totalactiveenergy;\n node.status({fill:\"green\",shape:\"ring\",text:\"Charging\"});\n flow.set(\"chargestate\", chargestate);\n return [{\"topic\": \"start\", \"payload\": chargestate}];\n}\n\nif ((chargestate.charging) && (msg.payload.activepower < powerthreshold)) {\n // charging stopped\n chargestate.charging = false;\n chargestate.endtime = new Date().getTime();\n chargestate.endenergy = msg.payload.totalactiveenergy;\n chargestate.energyused = chargestate.endenergy - chargestate.startenergy;\n chargestate.chargetime_msec = chargestate.endtime - chargestate.starttime;\n chargestate.chargetime_text = convertMsToTime(chargestate.chargetime_msec);\n flow.set(\"chargestate\", chargestate);\n node.status({ fill: \"grey\", shape: \"ring\", text: \"Not charging, last charge: \" + chargestate.chargetime_text + \", \" + chargestate.energyused.toFixed(2)+ \" kWh\" });\n return [{ \"topic\": \"stop\", \"payload\": chargestate }];\n} \n\nif ((chargestate.charging) && (msg.payload.activepower > powerthreshold)) {\n // charging in progress\n chargestate.chargetime_msec = new Date().getTime() - chargestate.starttime;\n chargestate.chargetime_text = convertMsToTime(chargestate.chargetime_msec);\n chargestate.energyused = msg.payload.totalactiveenergy - chargestate.startenergy;\n flow.set(\"chargestate\", chargestate);\n node.status({ fill: \"green\", shape: \"ring\", text: \"Charging: \" + chargestate.chargetime_text + \", \" + msg.payload.current + \" A, \" + chargestate.energyused.toFixed(2) + \" kWh\" });\n return [{ \"topic\": \"charging\", \"payload\": chargestate }];\n} \n\n\n\n",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 460,
"y": 240,
"wires": [
[
"c7541ebec4b8a4b7",
"4733fa211b46536b"
]
]
},
{
"id": "b406a7931f92d1e0",
"type": "comment",
"z": "a05fd98639604605",
"name": "Charge state",
"info": "",
"x": 430,
"y": 160,
"wires": []
},
{
"id": "6ea0e5ed8ccbc622",
"type": "function",
"z": "a05fd98639604605",
"name": "Send charge report",
"func": "msg.payload = { \"service\": 23, \"type\": \"message\", \"content\": \"π Charging completed: \" + msg.payload.chargetime_text + \", energy used: \"+msg.payload.energyused.toFixed(2)+\" kWh\"};\nreturn msg;\n",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 890,
"y": 280,
"wires": [
[
"e2a7460baed461fb"
]
]
},
{
"id": "e2a7460baed461fb",
"type": "link out",
"z": "a05fd98639604605",
"name": "",
"mode": "link",
"links": [
"86deb2f58b76aa52"
],
"x": 1065,
"y": 280,
"wires": []
},
{
"id": "c7541ebec4b8a4b7",
"type": "switch",
"z": "a05fd98639604605",
"name": "Charging?",
"property": "topic",
"propertyType": "msg",
"rules": [
{
"t": "eq",
"v": "start",
"vt": "str"
},
{
"t": "eq",
"v": "stop",
"vt": "str"
},
{
"t": "eq",
"v": "charging",
"vt": "str"
}
],
"checkall": "true",
"repair": false,
"outputs": 3,
"x": 670,
"y": 220,
"wires": [
[
"6cb45b7890af32ce"
],
[
"6ea0e5ed8ccbc622",
"7d7e2d83c4cbeb05"
],
[
"accc6b4d95f0bc1a"
]
]
},
{
"id": "6cb45b7890af32ce",
"type": "function",
"z": "a05fd98639604605",
"name": "Charging started",
"func": "msg.payload = { \"service\": 23, \"type\": \"message\", \"content\": \"π Charging started\"};\nreturn msg;\n",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 890,
"y": 220,
"wires": [
[
"e2a7460baed461fb"
]
]
},
{
"id": "da684f4a493c7449",
"type": "function",
"z": "a05fd98639604605",
"name": "Online State (New)",
"func": "let devicename = \"EVCharger\"; // Device name used for context variable\nlet system_id = 49; // System id number for diagnostic update\nlet online_threshold = 10; // Seconds between updates under which the device is considered online\nlet offline_threshold = 60; // Seconds between updates above which the device is considered offline\nlet low_battery = 15; // Low battery warning below this value\nlet battery = -1;\n\nlet devicestatus = global.get(\"devicestatus\");\nif (devicestatus === undefined) {\n devicestatus = {};\n}\nif (devicestatus[devicename] === undefined) {\n devicestatus[devicename] = { \"systemid\": system_id };\n}\n\nif (msg.topic.indexOf(\"battery\") > -1) {\n battery = parseInt(msg.payload);\n}\n\n\nlet temp = devicestatus[devicename].lastupdate;\nvar current = new Date().getTime();\nmsg.payload = \"No data\";\nif (msg.topic !== \"timecheck\") {\n // Do not update the context if it is triggered by the check inject node\n devicestatus[devicename].lastupdate = current;\n temp = current;\n}\n\nif (temp !== undefined) {\n current = current - temp;\n current = Math.floor(current / 1000);\n var minute = Math.floor(current / 60);\n var hour = Math.floor(minute / 60);\n var day = Math.floor(hour / 24);\n if (current > 24 * 60 * 60) {\n msg.payload = \"Last update \" + day + \" days, \" + hour % 24 + \" hours, \" + minute % 60 + \" minutes, \" + current % 60 + \" seconds ago\";\n } else if (current > 60 * 60) {\n msg.payload = \"Last update \" + hour % 24 + \" hours, \" + minute % 60 + \" minutes, \" + current % 60 + \" seconds ago\";\n } else if (current > 60) {\n msg.payload = \"Last update \" + minute % 60 + \" minutes, \" + current % 60 + \" seconds ago\";\n } else {\n msg.payload = \"Last update \" + current % 60 + \" seconds ago\";\n }\n devicestatus[devicename].lastupdatetext = msg.payload;\n\n if (devicestatus[devicename].state !== 1) {\n if (current < online_threshold) {\n msg.payload = devicename+ \" is now online\";\n msg.system = system_id; // System id, use 1 for Dummy\n msg.state = 1; // specify if the message is to change system status\n msg.severity = 0; // 0: information, 1: warning, 2: error\n //msg.email = true; // if separate email should be sent\n //msg.emailtext = \"\"; this a long text which goes into the email \n msg.warning = true;\n devicestatus[devicename].state = msg.state;\n }\n } else {\n if (current > offline_threshold) {\n msg.payload = devicename + \" is not transmitting\";\n msg.system = system_id; // System id, use 1 for Dummy\n msg.state = 99; // specify if the message is to change system status\n msg.severity = 0; // 0: information, 1: warning, 2: error\n //msg.email = true; // if separate email should be sent\n //msg.emailtext = \"\"; this a long text which goes into the email \n msg.warning = true;\n devicestatus[devicename].state = msg.state;\n }\n if ((devicestatus[devicename].state === 1) && (battery < low_battery) && (battery !== -1)) {\n msg.payload = devicename + \"'s battery is low\";\n msg.system = system_id; // System id, use 1 for Dummy\n msg.state = 60; // specify if the message is to change system status\n msg.severity = 2; // 0: information, 1: warning, 2: error\n //msg.email = true; // if separate email should be sent\n //msg.emailtext = \"\"; this a long text which goes into the email \n msg.warning = true;\n devicestatus[devicename].state = msg.state;\n }\n if ((devicestatus[devicename].state === 60) && (battery > low_battery) && (battery !== -1)) {\n msg.payload = devicename + \" has a new battery\";\n msg.system = system_id; // System id, use 1 for Dummy\n msg.state = 1; // specify if the message is to change system status\n msg.severity = 0; // 0: information, 1: warning, 2: error\n //msg.email = true; // if separate email should be sent\n //msg.emailtext = \"\"; this a long text which goes into the email \n msg.warning = true;\n devicestatus[devicename].state = msg.state;\n }\n }\n\n\n}\n\nglobal.set(\"devicestatus\", devicestatus);\nnode.status({ fill: \"blue\", shape: \"ring\", text: msg.payload });\n\nreturn msg;\n\n\n",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 410,
"y": 440,
"wires": [
[
"538097c7af40e7ab"
]
]
},
{
"id": "785fc28ca8c4b348",
"type": "link out",
"z": "a05fd98639604605",
"name": "",
"links": [
"13e089a7.73cb46"
],
"x": 795,
"y": 440,
"wires": []
},
{
"id": "538097c7af40e7ab",
"type": "switch",
"z": "a05fd98639604605",
"name": "Update diag?",
"property": "warning",
"propertyType": "msg",
"rules": [
{
"t": "true"
}
],
"checkall": "true",
"outputs": 1,
"x": 610,
"y": 440,
"wires": [
[
"785fc28ca8c4b348",
"321ab4b7dd312967"
]
]
},
{
"id": "321ab4b7dd312967",
"type": "switch",
"z": "a05fd98639604605",
"name": "Offline?",
"property": "state",
"propertyType": "msg",
"rules": [
{
"t": "eq",
"v": "99",
"vt": "num"
},
{
"t": "eq",
"v": "1",
"vt": "num"
}
],
"checkall": "true",
"repair": false,
"outputs": 2,
"x": 440,
"y": 360,
"wires": [
[
"b13792f5fd99aca2",
"b32250bdb77d3194"
],
[
"bd7de38ab75df6e1"
]
]
},
{
"id": "b13792f5fd99aca2",
"type": "function",
"z": "a05fd98639604605",
"name": "Dummy message",
"func": "msg.payload = { \"voltage\": 0, \"current\": 0, \"activepower\": 0, \"frequency\": 0, \"powerfactor\": 0, \"totalactiveenergy\": -1};\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 610,
"y": 320,
"wires": [
[
"07a85e1da2ae5051"
]
]
},
{
"id": "b32250bdb77d3194",
"type": "function",
"z": "a05fd98639604605",
"name": "Power off",
"func": "msg.payload = { \"service\": 23, \"type\": \"message\", \"content\": \"π Off peak tariff ended\"};\nreturn msg;\n",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 840,
"y": 340,
"wires": [
[
"e2a7460baed461fb"
]
]
},
{
"id": "bd7de38ab75df6e1",
"type": "function",
"z": "a05fd98639604605",
"name": "Power on",
"func": "msg.payload = { \"service\": 23, \"type\": \"message\", \"content\": \"π Off peak tariff started\"};\nreturn msg;\n",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 840,
"y": 380,
"wires": [
[
"e2a7460baed461fb"
]
]
},
{
"id": "a776c0f64b849620",
"type": "inject",
"z": "a05fd98639604605",
"name": "Update",
"props": [
{
"p": "payload"
},
{
"p": "topic",
"vt": "str"
}
],
"repeat": "2",
"crontab": "",
"once": false,
"onceDelay": 0.1,
"topic": "timecheck",
"payload": "",
"payloadType": "date",
"x": 120,
"y": 500,
"wires": [
[
"da684f4a493c7449"
]
]
},
{
"id": "7ef13bf3d29cf4c0",
"type": "debug",
"z": "a05fd98639604605",
"name": "Influx playload",
"active": false,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "payload",
"targetType": "msg",
"statusVal": "",
"statusType": "auto",
"x": 1110,
"y": 160,
"wires": []
},
{
"id": "accc6b4d95f0bc1a",
"type": "debug",
"z": "a05fd98639604605",
"name": "Charge update",
"active": false,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "payload",
"targetType": "msg",
"statusVal": "",
"statusType": "auto",
"x": 1020,
"y": 520,
"wires": []
},
{
"id": "4733fa211b46536b",
"type": "debug",
"z": "a05fd98639604605",
"name": "debug 12",
"active": false,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "false",
"statusVal": "",
"statusType": "auto",
"x": 650,
"y": 160,
"wires": []
},
{
"id": "d5695bc1e633148f",
"type": "debug",
"z": "a05fd98639604605",
"name": "debug 13",
"active": false,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "false",
"statusVal": "",
"statusType": "auto",
"x": 210,
"y": 260,
"wires": []
},
{
"id": "7d7e2d83c4cbeb05",
"type": "function",
"z": "a05fd98639604605",
"name": "Daily stats",
"func": "function padTo2Digits(num) {\n return num.toString().padStart(2, '0');\n}\n\nfunction convertMsToTime(milliseconds) {\n let seconds = Math.floor(milliseconds / 1000);\n let minutes = Math.floor(seconds / 60);\n let hours = Math.floor(minutes / 60);\n\n seconds = seconds % 60;\n minutes = minutes % 60;\n\n // ποΈ If you don't want to roll hours over, e.g. 24 to 00\n // ποΈ comment (or remove) the line below\n // commenting next line gets you `24:00:00` instead of `00:00:00`\n // or `36:15:31` instead of `12:15:31`, etc.\n // hours = hours % 24;\n\n return padTo2Digits(hours) + \":\" + padTo2Digits(minutes) + \":\" + padTo2Digits(seconds);\n}\n\nlet ev = flow.get(\"ev\");\nif (ev === undefined) {\n ev = {};\n flow.set(\"ev\", ev);\n}\n\nif (msg.topic === \"nextday\") {\n // reset the counter and copy the values to yesterday\n delete ev.yesterday;\n if (ev.today !== undefined) {\n ev.yesterday = ev.today;\n delete ev.today;\n ev.charges = [];\n flow.set(\"ev\", ev);\n }\n} else {\n // accumulate the charge data for the current day\n if (ev.today === undefined) {\n // initiate the structure for first use\n ev.today = {};\n ev.today.chargetime_msec = 0;\n ev.today.energyused = 0.0;\n }\n ev.today.energyused += msg.payload.energyused;\n ev.today.chargetime_msec += msg.payload.chargetime_msec\n ev.today.chargetime_text = convertMsToTime(ev.today.chargetime_msec);\n ev.charges.push(msg.payload);\n flow.set(\"ev\", ev);\n}\n\nmsg.payload = ev;\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1210,
"y": 400,
"wires": [
[
"c32301e856f92608"
]
]
},
{
"id": "03f80aaf635667d0",
"type": "inject",
"z": "a05fd98639604605",
"name": "Reset stats",
"props": [
{
"p": "payload"
},
{
"p": "topic",
"vt": "str"
}
],
"repeat": "",
"crontab": "00 08 * * *",
"once": false,
"onceDelay": 0.1,
"topic": "nextday",
"payload": "",
"payloadType": "date",
"x": 1020,
"y": 440,
"wires": [
[
"7d7e2d83c4cbeb05"
]
]
},
{
"id": "0ed6db05a301a58a",
"type": "link out",
"z": "a05fd98639604605",
"name": "",
"mode": "link",
"links": [
"0d3a8155db4e5123",
"783d71afcd0cb582"
],
"x": 1765,
"y": 400,
"wires": []
},
{
"id": "18122d80545ff87b",
"type": "function",
"z": "a05fd98639604605",
"name": "Convert to Influx",
"func": "if (msg.payload.yesterday !== undefined) {\n let record = {\n \"measurement\": \"sensors\",\n \"tags\": {\n \"device\": \"ev\"\n },\n \"fields\": msg.payload.yesterday\n };\n\n msg.payload = [record];\n return msg;\n}",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1620,
"y": 400,
"wires": [
[
"0ed6db05a301a58a"
]
]
},
{
"id": "c32301e856f92608",
"type": "switch",
"z": "a05fd98639604605",
"name": "Daily update?",
"property": "topic",
"propertyType": "msg",
"rules": [
{
"t": "eq",
"v": "nextday",
"vt": "str"
}
],
"checkall": "true",
"repair": false,
"outputs": 1,
"x": 1420,
"y": 400,
"wires": [
[
"18122d80545ff87b"
]
]
},
{
"id": "cea5258a.b34038",
"type": "mqtt-broker",
"name": "",
"broker": "192.168.1.80",
"port": "1883",
"clientid": "node-red",
"autoConnect": true,
"usetls": false,
"protocolVersion": "4",
"keepalive": "60",
"cleansession": true,
"birthTopic": "",
"birthQos": "0",
"birthPayload": "",
"birthMsg": {},
"closeTopic": "",
"closePayload": "",
"closeMsg": {},
"willTopic": "",
"willQos": "0",
"willPayload": "",
"willMsg": {},
"sessionExpiry": ""
}
]