[ { "id": "e600000000000001", "type": "tab", "label": "AlphaESS Dashboard (FlowFuse Dashboard 2.0)", "disabled": false, "info": "Bilingual (EN/DE) FlowFuse Dashboard 2.0 for @impact0815/node-red-contrib-alphaess-modbus.\n\nSETUP\n1. Move the three link-out nodes \"AlphaESS → Dashboard …\" into the tab of your AlphaESS node (Ctrl+X / Ctrl+V) and connect: output 1 → Data, output 2 → Alarm, output 3 → Response.\n2. Move the link-in node \"Dashboard command → AlphaESS\" there too and connect it to the INPUT of the AlphaESS node.\n3. Open /dashboard/alphaess.\n\nIf you already use Dashboard 2.0: delete the ui-base \"AlphaESS\" from the config nodes and assign the page \"AlphaESS\" to your own dashboard (only one ui-base per instance).\n\nSAFETY: write commands only while controls are unlocked (10 min), with two-step confirmation and server-side validation. \"Allow write access\" must be enabled in the AlphaESS node.\n\nPERSISTENCE: data and 24 h history use the \"file\" context store (fallback: default store)." }, { "id": "e600000000000002", "type": "ui-page", "name": "AlphaESS", "ui": "e6000000000000b0", "path": "/alphaess", "icon": "solar-power-variant", "layout": "grid", "theme": "e6000000000000b1", "breakpoints": [ { "name": "Default", "px": "0", "cols": "4" }, { "name": "Tablet", "px": "576", "cols": "8" }, { "name": "Desktop", "px": "1024", "cols": "12" } ], "order": 1, "className": "", "visible": "true", "disabled": "false" }, { "id": "e600000000000003", "type": "ui-group", "name": "AlphaESS", "page": "e600000000000002", "width": "12", "height": "1", "order": 1, "showTitle": false, "className": "", "visible": "true", "disabled": "false", "groupType": "default" }, { "id": "e60000000001000", "type": "comment", "z": "e600000000000001", "name": "① Move link-out nodes to your AlphaESS node and connect outputs 1/2/3 (see tab info)", "info": "", "x": 360, "y": 40, "wires": [] }, { "id": "e60000000002000", "type": "link out", "z": "e600000000000001", "name": "AlphaESS → Dashboard Data", "mode": "link", "links": [ "e60000000002100" ], "x": 180, "y": 100, "wires": [], "l": true, "info": "Verbinden mit: Ausgang 1 der AlphaESS-Node" }, { "id": "e60000000002100", "type": "link in", "z": "e600000000000001", "name": "Dashboard Data", "links": [ "e60000000002000" ], "x": 460, "y": 100, "wires": [ [ "e60000000002200" ] ], "l": true }, { "id": "e60000000002200", "type": "change", "z": "e600000000000001", "name": "Quelle: data", "rules": [ { "t": "set", "p": "source", "pt": "msg", "to": "data", "tot": "str" } ], "action": "", "property": "", "from": "", "to": "", "reg": false, "x": 660, "y": 100, "wires": [ [ "e600000000000050" ] ] }, { "id": "e60000000002001", "type": "link out", "z": "e600000000000001", "name": "AlphaESS → Dashboard Alarm", "mode": "link", "links": [ "e60000000002101" ], "x": 180, "y": 150, "wires": [], "l": true, "info": "Verbinden mit: Ausgang 2 der AlphaESS-Node" }, { "id": "e60000000002101", "type": "link in", "z": "e600000000000001", "name": "Dashboard Alarm", "links": [ "e60000000002001" ], "x": 460, "y": 150, "wires": [ [ "e60000000002201" ] ], "l": true }, { "id": "e60000000002201", "type": "change", "z": "e600000000000001", "name": "Quelle: alarm", "rules": [ { "t": "set", "p": "source", "pt": "msg", "to": "alarm", "tot": "str" } ], "action": "", "property": "", "from": "", "to": "", "reg": false, "x": 660, "y": 150, "wires": [ [ "e600000000000050" ] ] }, { "id": "e60000000002002", "type": "link out", "z": "e600000000000001", "name": "AlphaESS → Dashboard Response", "mode": "link", "links": [ "e60000000002102" ], "x": 180, "y": 200, "wires": [], "l": true, "info": "Verbinden mit: Ausgang 3 der AlphaESS-Node" }, { "id": "e60000000002102", "type": "link in", "z": "e600000000000001", "name": "Dashboard Response", "links": [ "e60000000002002" ], "x": 460, "y": 200, "wires": [ [ "e60000000002202" ] ], "l": true }, { "id": "e60000000002202", "type": "change", "z": "e600000000000001", "name": "Quelle: command", "rules": [ { "t": "set", "p": "source", "pt": "msg", "to": "command", "tot": "str" } ], "action": "", "property": "", "from": "", "to": "", "reg": false, "x": 660, "y": 200, "wires": [ [ "e600000000000050" ] ] }, { "id": "e600000000000050", "type": "function", "z": "e600000000000001", "name": "AlphaESS-Modell aktualisieren", "func": "/**\n * AlphaESS-Modell aktualisieren\n *\n * Nimmt die drei Ausgänge der AlphaESS-Modbus-Node entgegen\n * (msg.source per Change-Node gesetzt):\n *\n * \"data\" Ausgang 1: Echtzeitdaten inkl. daily, yesterday, details, info\n * \"alarm\" Ausgang 2: Alarme / Warnungen\n * \"command\" Ausgang 3: Antwort auf Schreibbefehle\n *\n * Speichert die letzten Daten sowie einen 24-h-Verlauf (2-Minuten-Mittelwerte)\n * im Context-Store \"file\", damit alles einen Neustart übersteht.\n *\n * Vorzeichen im Verlauf:\n * grid > 0 = Netzbezug, < 0 = Einspeisung\n * batt > 0 = Entladen, < 0 = Laden\n */\n\nconst STORE = \"file\";\nconst BUCKET_MS = 2 * 60 * 1000; // 2-Minuten-Raster\nconst HISTORY_MAX = 720; // 24 h\nconst ALARM_LOG_MAX = 100;\n\n// --------------------------------------------------\n// Context mit Fallback auf Standard-Store\n// --------------------------------------------------\n\nfunction load(key, fallback) {\n try {\n const v = flow.get(key, STORE);\n return v === undefined ? fallback : v;\n } catch (e) {\n const v = flow.get(key);\n return v === undefined ? fallback : v;\n }\n}\n\nfunction save(key, value) {\n try {\n flow.set(key, value, STORE);\n } catch (e) {\n flow.set(key, value);\n }\n}\n\nfunction num(value) {\n if (value === null || value === undefined || value === \"\") {\n return null;\n }\n const n = Number(value);\n return Number.isFinite(n) ? n : null;\n}\n\n// Alarm-/Warnungseintrag in lesbaren Text wandeln\nfunction itemText(item) {\n if (typeof item === \"string\") {\n return item;\n }\n if (item && typeof item === \"object\") {\n return String(item.textDe || item.text || item.message || item.name || item.code || JSON.stringify(item));\n }\n return String(item);\n}\n\nconst now = Date.now();\nconst model = load(\"alphaDash\", null) || { data: null, lastDataAt: null, alarm: null, alarmLog: [] };\nlet history = load(\"alphaDashHistory\", []);\n\n// --------------------------------------------------\n// Ausgang 1: Echtzeitdaten\n// --------------------------------------------------\n\nif (msg.source === \"data\") {\n\n const p = msg.payload;\n\n if (!p || typeof p !== \"object\") {\n node.status({ fill: \"yellow\", shape: \"ring\", text: \"Daten ohne Objekt-Payload\" });\n return null;\n }\n\n model.data = p;\n model.lastDataAt = new Date(now).toISOString();\n\n // Leistungswerte für den Verlauf\n const pv = num(p.modules) ?? num(p.details?.inverter?.pvPowerTotal);\n const load_ = num(p.consumption);\n const grid = num(p.grid);\n\n let batt = null;\n const charge = num(p.batteryCharge);\n const discharge = num(p.batteryDischarge);\n\n if (charge !== null || discharge !== null) {\n batt = (discharge || 0) - (charge || 0);\n } else if (num(p.battery) !== null) {\n batt = num(p.battery);\n }\n\n const soc = num(p.soc);\n\n // In 2-Minuten-Bucket mitteln\n const bucket = Math.floor(now / BUCKET_MS) * BUCKET_MS;\n const last = history[history.length - 1];\n const values = { pv, load: load_, grid, batt, soc };\n\n if (last && last.t === bucket) {\n\n const k = last.n || 1;\n\n for (const [key, v] of Object.entries(values)) {\n if (v === null) {\n continue;\n }\n last[key] = last[key] === null || last[key] === undefined\n ? v\n : Math.round(((last[key] * k + v) / (k + 1)) * 10) / 10;\n }\n\n last.n = k + 1;\n\n } else {\n history.push({ t: bucket, ...values, n: 1 });\n }\n\n const limit = now - 24 * 3600 * 1000;\n history = history.filter(h => h.t >= limit).slice(-HISTORY_MAX);\n}\n\n// --------------------------------------------------\n// Ausgang 2: Alarme / Warnungen\n// --------------------------------------------------\n\nif (msg.source === \"alarm\") {\n\n const p = msg.payload || {};\n const alarms = (Array.isArray(p.alarms) ? p.alarms : []).map(itemText);\n const warnings = (Array.isArray(p.warnings) ? p.warnings : []).map(itemText);\n const signature = JSON.stringify([alarms, warnings]);\n\n if (!model.alarm || model.alarm.signature !== signature) {\n\n model.alarmLog.unshift({\n at: new Date(now).toISOString(),\n alarms,\n warnings,\n cleared: alarms.length === 0 && warnings.length === 0\n });\n\n model.alarmLog = model.alarmLog.slice(0, ALARM_LOG_MAX);\n }\n\n model.alarm = { alarms, warnings, signature, at: new Date(now).toISOString() };\n}\n\n// --------------------------------------------------\n// Ausgang 3: Antwort auf Schreibbefehle\n// --------------------------------------------------\n\nif (msg.source === \"command\") {\n\n const written = Array.isArray(msg.written) ? msg.written.length : null;\n const topic = msg.topic || \"\";\n\n let de;\n let en;\n\n if (msg.unchanged === true) {\n de = `${topic}: keine Änderung erforderlich`;\n en = `${topic}: no change required`;\n } else if (written !== null) {\n de = `${topic}: ${written} Register geschrieben`;\n en = `${topic}: ${written} registers written`;\n } else {\n de = `${topic}: Antwort der Anlage erhalten`;\n en = `${topic}: response received`;\n }\n\n flow.set(\"alphaFeedback\", { ts: now, ok: true, de, en });\n}\n\nsave(\"alphaDash\", model);\nsave(\"alphaDashHistory\", history);\n\nnode.status({\n fill: \"green\",\n shape: \"dot\",\n text: `${msg.source} · ${history.length} Verlaufspunkte`\n});\n\nreturn { trigger: true };", "outputs": 1, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 900, "y": 150, "wires": [ [ "e600000000000051" ] ] }, { "id": "e60000000003000", "type": "inject", "z": "e600000000000001", "name": "Start / alle 30 s", "props": [], "repeat": "30", "crontab": "", "once": true, "onceDelay": "2", "topic": "", "x": 920, "y": 240, "wires": [ [ "e600000000000051" ] ] }, { "id": "e600000000000051", "type": "function", "z": "e600000000000001", "name": "Dashboard-Modell erzeugen", "func": "/**\n * Dashboard-Modell erzeugen (entprellt)\n *\n * Fasst Daten, Alarme, Verlauf, Freischaltung und Rückmeldungen\n * zu EINER Nachricht zusammen. Dadurch zeigt das Dashboard nach\n * einem Browser-Reload immer den vollständigen Zustand.\n */\n\nconst DEBOUNCE_MS = 300;\nconst STORE = \"file\";\n\nfunction load(key, fallback) {\n try {\n const v = flow.get(key, STORE);\n return v === undefined ? fallback : v;\n } catch (e) {\n const v = flow.get(key);\n return v === undefined ? fallback : v;\n }\n}\n\nfunction build() {\n\n const model = load(\"alphaDash\", null) || {};\n const history = load(\"alphaDashHistory\", []);\n const armedUntil = flow.get(\"alphaArmedUntil\") || 0;\n const now = Date.now();\n\n const age = model.lastDataAt ? Math.round((now - Date.parse(model.lastDataAt)) / 1000) : null;\n\n node.status({\n fill: age === null ? \"grey\" : age < 120 ? \"green\" : \"yellow\",\n shape: \"dot\",\n text: age === null ? \"Warte auf Daten\" : `Daten vor ${age} s · ${history.length} Punkte`\n });\n\n return {\n payload: {\n data: model.data || null,\n lastDataAt: model.lastDataAt || null,\n alarm: model.alarm || null,\n alarmLog: (model.alarmLog || []).slice(0, 30),\n history: history.map(h => ({ t: h.t, pv: h.pv, load: h.load, grid: h.grid, batt: h.batt, soc: h.soc })),\n armed: armedUntil > now,\n armedUntil,\n feedback: flow.get(\"alphaFeedback\") || null\n }\n };\n}\n\nconst timer = context.get(\"timer\");\n\nif (timer) {\n clearTimeout(timer);\n}\n\ncontext.set(\"timer\", setTimeout(() => {\n context.set(\"timer\", null);\n node.send(build());\n}, DEBOUNCE_MS));\n\nreturn null;", "outputs": 1, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 1160, "y": 190, "wires": [ [ "e600000000000053" ] ] }, { "id": "e600000000000053", "type": "ui-template", "z": "e600000000000001", "group": "e600000000000003", "page": "", "ui": "", "name": "AlphaESS Energiezentrale DE/EN", "order": 1, "width": "12", "height": "0", "head": "", "format": "\n\n\n\n", "storeOutMessages": true, "passthru": false, "resendOnRefresh": true, "templateScope": "local", "className": "", "x": 1430, "y": 150, "wires": [ [ "e600000000000052" ] ] }, { "id": "e600000000000052", "type": "function", "z": "e600000000000001", "name": "Befehl prüfen & senden", "func": "/**\n * Befehl prüfen & senden (Sicherheitsprüfung)\n *\n * Eingang vom Dashboard: msg.payload = { action, ... }\n *\n * action \"read\" | \"readInfo\" – nur lesend, immer erlaubt\n * action \"arm\" value true|false – Steuerung 10 min freischalten\n * action \"dispatch\" power, soc, duration – Laden (< 0) / Entladen (> 0)\n * action \"dispatchStop\"\n * action \"feedIn\" value 0–100 %\n * action \"timePeriod\" flag, chargeCutSoc, upsReserveSoc, charge1/2, discharge1/2\n *\n * Schreibbefehle nur, wenn\n * - die Steuerung serverseitig freigeschaltet ist (10 Minuten),\n * - confirm === true (zweistufige Bestätigung im Dashboard),\n * - alle Werte plausibel sind.\n *\n * Ausgang 1: Befehl an die AlphaESS-Node (topic + payload)\n * Ausgang 2: Trigger für \"Dashboard-Modell erzeugen\"\n *\n * Zusätzlich muss in der AlphaESS-Node \"Allow write access\" aktiv sein.\n */\n\nconst ARM_MS = 10 * 60 * 1000;\n\nconst cmd = msg.payload || {};\nconst now = Date.now();\n\nfunction feedback(ok, de, en) {\n flow.set(\"alphaFeedback\", { ts: now, ok, de, en });\n node.status({ fill: ok ? \"green\" : \"red\", shape: \"dot\", text: de });\n}\n\nfunction reject(de, en) {\n feedback(false, de, en);\n return [null, { trigger: true }];\n}\n\nfunction send(topic, payload, de, en) {\n feedback(true, de, en);\n const out = { topic };\n if (payload !== undefined) {\n out.payload = payload;\n }\n return [out, { trigger: true }];\n}\n\nconst finite = v => v !== null && v !== \"\" && Number.isFinite(Number(v));\nconst between = (v, a, b) => finite(v) && Number(v) >= a && Number(v) <= b;\nconst isTime = v => typeof v === \"string\" && /^([01]\\d|2[0-3]):[0-5]\\d$/.test(v);\n\n// --------------------------------------------------\n// Lesende Befehle und Freischaltung\n// --------------------------------------------------\n\nif (cmd.action === \"read\" || cmd.action === \"readInfo\") {\n return send(cmd.action, undefined, \"Daten werden gelesen\", \"Reading data\");\n}\n\nif (cmd.action === \"arm\") {\n const on = cmd.value === true;\n flow.set(\"alphaArmedUntil\", on ? now + ARM_MS : 0);\n feedback(true,\n on ? \"Steuerung für 10 Minuten freigeschaltet\" : \"Steuerung gesperrt\",\n on ? \"Controls unlocked for 10 minutes\" : \"Controls locked\");\n return [null, { trigger: true }];\n}\n\n// --------------------------------------------------\n// Schreibende Befehle: Freischaltung + Bestätigung\n// --------------------------------------------------\n\nconst writeActions = [\"dispatch\", \"dispatchStop\", \"feedIn\", \"timePeriod\"];\n\nif (!writeActions.includes(cmd.action)) {\n return reject(`Unbekannter Befehl ${cmd.action}`, `Unknown command ${cmd.action}`);\n}\n\nif ((flow.get(\"alphaArmedUntil\") || 0) <= now) {\n return reject(\"Blockiert: Steuerung ist nicht freigeschaltet\", \"Blocked: controls are locked\");\n}\n\nif (cmd.confirm !== true) {\n return reject(\"Blockiert: keine Bestätigung\", \"Blocked: not confirmed\");\n}\n\nswitch (cmd.action) {\n\n case \"dispatchStop\":\n return send(\"dispatchStop\", undefined, \"Dispatch-Stopp gesendet\", \"Dispatch stop sent\");\n\n case \"dispatch\": {\n\n const power = Number(cmd.power);\n\n if (!finite(cmd.power) || power === 0 || Math.abs(power) > 100000) {\n return reject(\"Ungültige Dispatch-Leistung\", \"Invalid dispatch power\");\n }\n if (!between(cmd.soc, 0, 100)) {\n return reject(\"Ungültiger Ziel-SOC\", \"Invalid target SOC\");\n }\n if (!between(cmd.duration, 10, 86400)) {\n return reject(\"Ungültige Dauer (10 s – 24 h)\", \"Invalid duration (10 s – 24 h)\");\n }\n\n const payload = {\n power: Math.round(power),\n soc: Number(cmd.soc),\n duration: Math.round(Number(cmd.duration)),\n mode: 2\n };\n\n const dir = power < 0 ? \"Laden\" : \"Entladen\";\n const dirEn = power < 0 ? \"charge\" : \"discharge\";\n\n return send(\"dispatch\", payload,\n `Dispatch gesendet: ${dir} ${Math.abs(payload.power)} W bis ${payload.soc} %`,\n `Dispatch sent: ${dirEn} ${Math.abs(payload.power)} W to ${payload.soc} %`);\n }\n\n case \"feedIn\": {\n\n if (!between(cmd.value, 0, 100)) {\n return reject(\"Einspeisegrenze muss 0–100 % sein\", \"Feed-in limit must be 0–100 %\");\n }\n\n const value = Math.round(Number(cmd.value));\n\n return send(\"feedIn\", value,\n `Einspeisegrenze ${value} % gesendet`,\n `Feed-in limit ${value} % sent`);\n }\n\n case \"timePeriod\": {\n\n if (![0, 1, 2, 3].includes(Number(cmd.flag))) {\n return reject(\"Ungültige Zeitfenster-Betriebsart\", \"Invalid time-period mode\");\n }\n if (!between(cmd.chargeCutSoc, 0, 100) || !between(cmd.upsReserveSoc, 0, 100)) {\n return reject(\"SOC-Werte müssen 0–100 % sein\", \"SOC values must be 0–100 %\");\n }\n\n const payload = {\n flag: Number(cmd.flag),\n chargeCutSoc: Number(cmd.chargeCutSoc),\n upsReserveSoc: Number(cmd.upsReserveSoc)\n };\n\n for (const key of [\"charge1\", \"charge2\", \"discharge1\", \"discharge2\"]) {\n\n const slot = cmd[key];\n\n if (!slot || !isTime(slot.start) || !isTime(slot.stop)) {\n return reject(\"Zeiten müssen HH:MM sein\", \"Times must use HH:MM\");\n }\n\n payload[key] = { start: slot.start, stop: slot.stop };\n }\n\n return send(\"timePeriod\", payload, \"Zeitfenster gesendet\", \"Time periods sent\");\n }\n}\n\nreturn reject(\"Unbekannter Fehler\", \"Unknown error\");", "outputs": 2, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "outputLabels": [ "→ AlphaESS-Node", "Dashboard aktualisieren" ], "x": 900, "y": 380, "wires": [ [ "e60000000004000" ], [ "e600000000000051" ] ] }, { "id": "e60000000004000", "type": "link out", "z": "e600000000000001", "name": "Befehl an AlphaESS", "mode": "link", "links": [ "e60000000004100" ], "x": 1140, "y": 380, "wires": [], "l": true }, { "id": "e60000000004100", "type": "link in", "z": "e600000000000001", "name": "Dashboard command → AlphaESS", "links": [ "e60000000004000" ], "x": 1430, "y": 380, "wires": [ [] ], "l": true, "info": "In den Tab der AlphaESS-Node verschieben und mit deren EINGANG verbinden." }, { "id": "e60000000001001", "type": "comment", "z": "e600000000000001", "name": "② Move link-in node to your AlphaESS node and connect it to its input", "info": "", "x": 1380, "y": 330, "wires": [] }, { "id": "e6000000000000b0", "type": "ui-base", "name": "AlphaESS", "path": "/dashboard", "appIcon": "", "includeClientData": true, "acceptsClientConfig": [ "ui-notification", "ui-control" ], "showPathInSidebar": false, "headerContent": "page", "navigationStyle": "default", "titleBarStyle": "default", "showReconnectNotification": true, "notificationDisplayTime": 5, "showDisconnectNotification": true, "allowInstall": false }, { "id": "e6000000000000b1", "type": "ui-theme", "name": "AlphaESS Theme", "colors": { "surface": "#ffffff", "primary": "#0f766e", "bgPage": "#eef3f6", "groupBg": "#ffffff", "groupOutline": "#d8e1e8" }, "sizes": { "density": "default", "pagePadding": "12px", "groupGap": "12px", "groupBorderRadius": "18px", "widgetGap": "12px" } } ]