nomos system nomos system

Geräteprofil · Wallboxen

Easee Wallbox

Die Wallbox hat keine Schnittstelle im eigenen Netz — sie spricht ausschließlich mit dem Rechenzentrum des Herstellers. nomos liest sie deshalb über einen Node-RED-Flow, der sich dort mit den Kontodaten anmeldet, die Werte abholt und auf MQTT veröffentlicht. Laden pausieren, fortsetzen und die Stromgrenze gehen denselben Weg zurück.

Ein Flow bedient alle Wallboxen des Kontos. In nomos wird je Wallbox ein Gerät angelegt.

Voraussetzungen

  1. Ein MQTT-Broker, in nomos unter Skills → MQTT eingetragen.
  2. Node-RED mit Zugriff auf denselben Broker. Der Flow braucht nur Standardknoten.
  3. Die Zugangsdaten des Easee-Kontos und die Seriennummer jeder Wallbox. Die Seriennummer steht auf dem Gerät und in der App des Herstellers; sie beginnt mit EH.
  4. Eine Internetverbindung des Controllers — ohne sie bleiben die Werte stehen.

Den Flow einfügen

In Node-RED: Menü rechts oben (☰) → Import → den JSON-Text einfügen → Import → Deploy.

JSON zum Einfügen
[{"id":"nomos_easee","type":"tab","label":"Easee Wallbox","disabled":false,"info":""},{"id":"nomos_easee_broker","type":"mqtt-broker","name":"nomos Broker","broker":"localhost","port":"1883","clientid":"","autoConnect":true,"usetls":false,"protocolVersion":"4","keepalive":"60","cleansession":true,"birthTopic":"","birthQos":"0","birthPayload":"","birthMsg":{},"closeTopic":"","closeQos":"0","closePayload":"","closeMsg":{},"willTopic":"","willQos":"0","willPayload":"","willMsg":{},"userProps":"","sessionExpiry":""},{"id":"nomos_easee_tick","type":"inject","z":"nomos_easee","name":"alle 60 Sekunden","x":150,"y":140,"wires":[["nomos_easee_settings"]],"props":[{"p":"payload"}],"repeat":"60","crontab":"","once":true,"onceDelay":"5","topic":"","payload":"","payloadType":"date"},{"id":"nomos_easee_settings","type":"function","z":"nomos_easee","name":"Zugangsdaten","x":340,"y":140,"wires":[["nomos_easee_auth"]],"func":"// Hier eintragen: Zugangsdaten des Easee-Kontos und die Seriennummern der\n// Wallboxen (sie stehen auf dem Geraet, z. B. EH123456).\nflow.set(\"easeeUser\", \"name@example.com\");\nflow.set(\"easeePassword\", \"KENNWORT-HIER\");\nflow.set(\"easeeChargers\", [\"EH000000\"]);\n// Topic-Wurzel; das Geraet in nomos bekommt spaeter easee/<Seriennummer>\nflow.set(\"easeeRoot\", \"easee\");\nreturn msg;\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[]},{"id":"nomos_easee_auth","type":"function","z":"nomos_easee","name":"Anmelden oder abfragen","x":560,"y":140,"wires":[["nomos_easee_login"],["nomos_easee_refresh"],["nomos_easee_poll"]],"func":"// Drei Wege: anmelden, Token erneuern oder direkt abfragen.\n// Das Zugriffstoken gilt eine Stunde; eine Minute vorher wird erneuert.\nconst now = Date.now();\nconst token = flow.get(\"easeeToken\");\nconst expires = flow.get(\"easeeExpires\") || 0;\nconst refresh = flow.get(\"easeeRefresh\");\n\nif (token && now < expires - 60000) { return [null, null, msg]; }\n\nif (refresh) {\n    msg.url = \"https://api.easee.com/api/accounts/refresh_token\";\n    msg.headers = { \"content-type\": \"application/json\" };\n    msg.payload = { refreshToken: refresh };\n    return [null, msg, null];\n}\n\nmsg.url = \"https://api.easee.com/api/accounts/login\";\nmsg.headers = { \"content-type\": \"application/json\" };\nmsg.payload = { userName: flow.get(\"easeeUser\"), password: flow.get(\"easeePassword\") };\nreturn [msg, null, null];\n","outputs":3,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[]},{"id":"nomos_easee_login","type":"http request","z":"nomos_easee","name":"Anmeldung","x":780,"y":80,"wires":[["nomos_easee_token"]],"method":"POST","ret":"obj","paytoqs":"ignore","url":"","tls":"","persist":false,"proxy":"","insecureHTTPParser":false,"authType":"","senderr":false,"headers":[]},{"id":"nomos_easee_refresh","type":"http request","z":"nomos_easee","name":"Token erneuern","x":780,"y":140,"wires":[["nomos_easee_token"]],"method":"POST","ret":"obj","paytoqs":"ignore","url":"","tls":"","persist":false,"proxy":"","insecureHTTPParser":false,"authType":"","senderr":false,"headers":[]},{"id":"nomos_easee_token","type":"function","z":"nomos_easee","name":"Token merken","x":980,"y":110,"wires":[["nomos_easee_poll"]],"func":"// Antwort der Anmeldung bzw. der Erneuerung auswerten\nconst p = msg.payload || {};\nif (!p.accessToken) {\n    node.warn(\"keine Anmeldung moeglich - Benutzername und Kennwort pruefen\");\n    flow.set(\"easeeToken\", null);\n    flow.set(\"easeeRefresh\", null);\n    return null;\n}\nflow.set(\"easeeToken\", p.accessToken);\nflow.set(\"easeeRefresh\", p.refreshToken || null);\nflow.set(\"easeeExpires\", Date.now() + (Number(p.expiresIn) || 3600) * 1000);\nreturn msg;\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[]},{"id":"nomos_easee_poll","type":"function","z":"nomos_easee","name":"Wallboxen abfragen","x":580,"y":240,"wires":[["nomos_easee_observations"]],"func":"// Je Wallbox eine Abfrage. Ein Aufruf liefert alle angeforderten Beobachtungen.\nconst token = flow.get(\"easeeToken\");\nif (!token) { return null; }\nconst chargers = flow.get(\"easeeChargers\") || [];\nif (!chargers.length) { node.warn(\"keine Seriennummer eingetragen\"); return null; }\n\nconst IDS = \"109,120,121,124,114,48,103,96,102,250\";\nconst out = chargers.map((serial) => ({\n    serial: String(serial).trim(),\n    method: \"GET\",\n    url: \"https://api.easee.com/state/\" + encodeURIComponent(String(serial).trim())\n         + \"/observations?ids=\" + IDS,\n    headers: { \"Authorization\": \"Bearer \" + token },\n    payload: \"\"\n}));\nreturn [out];\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[]},{"id":"nomos_easee_observations","type":"http request","z":"nomos_easee","name":"Werte lesen","x":800,"y":240,"wires":[["nomos_easee_publish"]],"method":"GET","ret":"obj","paytoqs":"ignore","url":"","tls":"","persist":false,"proxy":"","insecureHTTPParser":false,"authType":"","senderr":false,"headers":[]},{"id":"nomos_easee_publish","type":"function","z":"nomos_easee","name":"Werte veroeffentlichen","x":1000,"y":240,"wires":[["nomos_easee_out"]],"func":"// Beobachtungen in Topics uebersetzen. Einheiten und Bedeutungen nach der\n// Dokumentation von Easee: 120 in kW, 121 und 124 in kWh, 114 und 48 in A.\nconst root = flow.get(\"easeeRoot\") || \"easee\";\nconst serial = msg.serial;\nconst body = msg.payload;\n\nif (!body || !Array.isArray(body.observations)) {\n    node.warn(\"keine Beobachtungen fuer \" + serial + \" - Anmeldung wird erneuert\");\n    flow.set(\"easeeToken\", null);\n    return [[{ topic: \"tele/\" + root + \"/\" + serial + \"/LWT\", payload: \"Offline\",\n               retain: true }]];\n}\n\nconst seen = {};\nbody.observations.forEach((o) => { seen[Number(o.id)] = o.value; });\n\nconst MODES = {\n    0: \"Offline\", 1: \"Nicht verbunden\", 2: \"Wartet auf Start\", 3: \"Laedt\",\n    4: \"Abgeschlossen\", 5: \"Stoerung\", 6: \"Bereit\", 7: \"Wartet auf Freigabe\",\n    8: \"Freigabe wird aufgehoben\"\n};\nconst REASONS = {\n    0: \"\", 1: \"Lastmanagement: Stromkreis zu klein\",\n    2: \"Lastmanagement: dynamische Grenze des Stromkreises zu klein\",\n    3: \"Lastmanagement: Notgrenze zu klein\", 4: \"Lastmanagement: Absicherung zu klein\",\n    5: \"Lastmanagement: in der Warteschlange\",\n    6: \"Lastmanagement: Warteschlange, Fahrzeug ist voll\",\n    7: \"Netzform konnte nicht erkannt werden\",\n    8: \"Keine Stromanforderung vom Fahrzeug\", 9: \"Verbindung zur Hauptbox verloren\",\n    10: \"Strom vom Equalizer zu gering\", 11: \"Phase nicht angeschlossen\",\n    25: \"Begrenzt durch die Absicherung\", 26: \"Begrenzt durch den Stromkreis\",\n    27: \"Begrenzt durch die dynamische Grenze des Stromkreises\",\n    28: \"Begrenzt durch den Equalizer\", 29: \"Begrenzt durch das Lastmanagement\",\n    30: \"Begrenzt durch die Offline-Einstellungen\",\n    50: \"Kein Fahrzeug angeschlossen\", 51: \"Maximalstrom der Wallbox zu klein\",\n    52: \"Dynamische Grenze der Wallbox zu klein\", 53: \"Wallbox ist deaktiviert\",\n    54: \"Zeitsteuerung aktiv\", 55: \"Wartet auf Freigabe\", 56: \"Wallbox meldet Stoerung\",\n    57: \"Fahrzeug verhaelt sich unregelmaessig\", 75: \"Begrenzt durch das Kabel\",\n    76: \"Begrenzt durch die Zeitsteuerung\", 77: \"Begrenzt durch den Maximalstrom\",\n    78: \"Begrenzt durch die dynamische Grenze\", 79: \"Fahrzeug laedt nicht\",\n    80: \"Grenze wurde gerade angepasst\", 81: \"Begrenzt durch das Fahrzeug\",\n    100: \"Unbekannter Fehler\"\n};\n\nconst mode = seen[109] === undefined ? null : Number(seen[109]);\nconst reasonCode = seen[96] === undefined ? null : Number(seen[96]);\nconst bool = (v) => (v === true || v === 1 || v === \"true\" ? 1 : 0);\nconst numberOf = (v) => {\n    if (v === undefined || v === null || v === \"\") return null;\n    const n = Number(v);\n    return isNaN(n) ? null : n;\n};\nconst round2 = (n) => Math.round(n * 100) / 100;\n\nconst values = {};\nif (mode !== null) {\n    values.STATE = mode;\n    values.CHARGING = mode === 3 ? 1 : 0;\n    values.CONNECTED = [2, 3, 4, 6, 7, 8].indexOf(mode) >= 0 ? 1 : 0;\n    values.MALFUNCTION = mode === 5 ? 1 : 0;\n}\nconst power = numberOf(seen[120]);\nif (power !== null) { values.POWER = Math.round(power * 1000); }   // kW -> W\nconst session = numberOf(seen[121]);\nif (session !== null) { values.ENERGY_SESSION = round2(session); }\nconst total = numberOf(seen[124]);\nif (total !== null) { values.ENERGY_TOTAL = round2(total); }\nconst current = numberOf(seen[114]);\nif (current !== null) { values.CURRENT = round2(current); }\nconst limit = numberOf(seen[48]);\nif (limit !== null) { values.LIMIT = Math.round(limit); }\nif (seen[103] !== undefined) { values.CABLE = bool(seen[103]); }\nif (seen[102] !== undefined) { values.SMART = bool(seen[102]); }\nif (seen[250] !== undefined) { values.ONLINE = bool(seen[250]); }\nif (reasonCode !== null) {\n    values.REASON = REASONS[reasonCode] !== undefined\n        ? REASONS[reasonCode] : (\"Code \" + reasonCode);\n}\nif (values.STATE !== undefined && values.REASON === undefined) { values.REASON = \"\"; }\n\nconst topic = root + \"/\" + serial;\nconst out = Object.keys(values).map((key) => ({\n    topic: \"stat/\" + topic + \"/\" + key, payload: values[key], retain: true\n}));\n// Das Lebenszeichen beschreibt die Wallbox: Betriebsart 0 heisst offline\nconst online = mode === null ? false : mode !== 0;\nout.push({ topic: \"tele/\" + topic + \"/LWT\", payload: online ? \"Online\" : \"Offline\",\n           retain: true });\nif (!out.length) { return null; }\nreturn [out];\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[]},{"id":"nomos_easee_out","type":"mqtt out","z":"nomos_easee","name":"an nomos","x":1230,"y":240,"wires":[],"topic":"","qos":"0","retain":"true","respTopic":"","contentType":"","userProps":"","correl":"","expiry":"","broker":"nomos_easee_broker"},{"id":"nomos_easee_in","type":"mqtt in","z":"nomos_easee","name":"Befehle aus nomos","x":150,"y":360,"wires":[["nomos_easee_command"]],"topic":"cmnd/easee/+/+","qos":"0","datatype":"auto-detect","broker":"nomos_easee_broker","nl":false,"rap":true,"rh":0,"inputs":0},{"id":"nomos_easee_command","type":"function","z":"nomos_easee","name":"Befehl umsetzen","x":390,"y":360,"wires":[["nomos_easee_send"]],"func":"// Befehl aus nomos in einen Aufruf der Schnittstelle uebersetzen.\n// Topic: cmnd/<Wurzel>/<Seriennummer>/<BEFEHL>\nconst token = flow.get(\"easeeToken\");\nif (!token) { node.warn(\"noch nicht angemeldet - Befehl verworfen\"); return null; }\n\nconst parts = String(msg.topic).split(\"/\");\nconst command = parts[parts.length - 1];\nconst serial = parts[parts.length - 2];\nconst chargers = (flow.get(\"easeeChargers\") || []).map((s) => String(s).trim());\nif (chargers.indexOf(serial) < 0) {\n    node.warn(\"unbekannte Seriennummer im Topic: \" + msg.topic);\n    return null;\n}\n\nconst value = String(msg.payload).trim();\nconst on = value === \"1\" || value.toLowerCase() === \"on\" || value.toLowerCase() === \"true\";\nconst base = \"https://api.easee.com/api/chargers/\" + encodeURIComponent(serial) + \"/commands/\";\n\nif (command === \"POWER\") {\n    // Pause und Fortsetzen lassen die Ladesitzung bestehen\n    msg.url = base + (on ? \"resume_charging\" : \"pause_charging\");\n    msg.payload = {};\n} else if (command === \"LIMIT\") {\n    const amps = Math.round(Number(value));\n    if (isNaN(amps) || amps < 0 || amps > 32) {\n        node.warn(\"Stromgrenze ausserhalb 0 bis 32 A: \" + value);\n        return null;\n    }\n    // minutes = 0 heisst: die Grenze gilt, bis eine andere gesetzt wird\n    msg.url = base + \"set_dynamic_charger_current\";\n    msg.payload = { amps: amps, minutes: 0 };\n} else {\n    node.warn(\"unbekannter Befehl: \" + command);\n    return null;\n}\n\nmsg.method = \"POST\";\nmsg.headers = { \"Authorization\": \"Bearer \" + token, \"content-type\": \"application/json\" };\nreturn msg;\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[]},{"id":"nomos_easee_send","type":"http request","z":"nomos_easee","name":"Befehl senden","x":620,"y":360,"wires":[["nomos_easee_poll"]],"method":"POST","ret":"obj","paytoqs":"ignore","url":"","tls":"","persist":false,"proxy":"","insecureHTTPParser":false,"authType":"","senderr":false,"headers":[]}]

Danach zwei Knoten anpassen:

Das Gerät in nomos anlegen

  1. Skills → Add-ons: das Profil importieren
  2. Je Wallbox ein Gerät anlegen und als MQTT-Topic easee/<Seriennummer> eintragen:
easee/EH123456

Was der Flow veröffentlicht

Topic Inhalt
stat/<Topic>/STATE Betriebsart als Zahl: 0 offline, 1 nicht verbunden, 2 wartet auf Start, 3 lädt, 4 abgeschlossen, 5 Störung, 6 bereit, 7 wartet auf Freigabe, 8 Freigabe wird aufgehoben
stat/<Topic>/CHARGING 1, solange geladen wird
stat/<Topic>/CONNECTED 1, solange ein Fahrzeug angeschlossen ist
stat/<Topic>/POWER Leistung in W
stat/<Topic>/CURRENT Ladestrom in A
stat/<Topic>/ENERGY_SESSION Energie der laufenden Ladung in kWh
stat/<Topic>/ENERGY_TOTAL Energie seit Inbetriebnahme in kWh
stat/<Topic>/LIMIT geltende Stromgrenze in A
stat/<Topic>/REASON im Klartext, warum gerade nicht geladen wird
stat/<Topic>/MALFUNCTION 1, wenn die Wallbox eine Störung meldet
stat/<Topic>/CABLE, SMART, ONLINE ausgeblendete Zusatzwerte
tele/<Topic>/LWT Online, solange die Wallbox erreichbar ist

In Gegenrichtung hört der Flow auf cmnd/easee/<Seriennummer>/POWER und /LIMIT.

Datenbereich

Schaltbar sind Ein/Aus und die Stromgrenze (0 bis 32 A).

Hinweise