PV inverter control via Node-RED

Continuing the discussion from How to create a new PV-inverter Dbus (+VRM) instance with Node-Red:

Interested to here where you go with this. I’ve got a Growatt inverter that I will be adding to AC out on a Multiplus II 6k5 and would like to control it from the Cerbo.

I’ve got a ESP32 connected to the Comm output on the Growatt and have it talking to Home Assistant now (this project). I could get the Cerbo to ping the Home Assistant API to make power level and on/off changes changes via Node-RED but it would be much better to have the Cerbo do it directly.

I have created a proof of concept node-red flow controlling my Growatt inverters. It’s using OpenInverter project which is a replacement firmware for Growatts wifi stick allowing full control locally. In my configuration this is done by configuring it to use the Cerbo’s intertal MQTT server. Since Node-Red is also running on the Cerbo I keep all the control in one place. GitHub - OpenInverterGateway/OpenInverterGateway: Firmware replacement for Growatt ShineWiFi-S . Apart from Growatt it also on/off controls a 2 panel inverter from APSystems using a HomeWizard smart socket.

This flow fetches the current EnergyZero electricity price every 5 minutes and uses it to control PV output and Victron ESS behavior. Once the Victron Minimum Discharge SOC setting is known, it runs in two modes:
• If price > 0, PV is enabled at 100%, throttling is disabled, and ESS target/min SOC is set to the configured minimum.
• If price ≤ 0, PV is set to 0%, throttling is enabled, and ESS target SOC is set to 100% (prefers charging/importing).

It also listens for grid-loss alarms and battery SOC. On grid loss it normally turns PV off, but if the battery SOC falls below 50% during an outage it forces PV to 100% (emergency override) until SOC recovers to 90%, then clears the override. Commands go out via MQTT to multiple PV controllers plus an HTTP call to toggle a PV shed switch.

I hope this is useful for someone.

[
    {
        "id": "bdbb4b2f64a2ea6f",
        "type": "group",
        "z": "b51fc0718e9c7f9d",
        "style": {
            "stroke": "#999999",
            "stroke-opacity": "1",
            "fill": "none",
            "fill-opacity": "1",
            "label": true,
            "label-position": "nw",
            "color": "#a4a4a4"
        },
        "nodes": [
            "e5c6a0996694e615",
            "29fb60c427c39f8e",
            "f187f03a749a140c",
            "a1fa04f4896cc614",
            "169dab67c8eecb5c",
            "795f70d361eb852d",
            "e48a83570023d43f",
            "eb98fb36c0858bfc",
            "f9ecf63ba68d3e89",
            "03e042b6c49e7145",
            "7a1d5ae47e397e2e",
            "2d3733302885b499",
            "d36b13b7e53900e3",
            "e322d0801857ae34",
            "41017fefc353bd5a",
            "b2986e48ca7cc4d9",
            "927efb2a9b581949",
            "4986f56544299c23",
            "948625324d0ad0aa",
            "3aecf133104345cb"
        ],
        "x": 34,
        "y": 1659,
        "w": 1392,
        "h": 562
    },
    {
        "id": "e5c6a0996694e615",
        "type": "mqtt out",
        "z": "b51fc0718e9c7f9d",
        "g": "bdbb4b2f64a2ea6f",
        "name": "",
        "topic": "Solar/garage/command/power/set/activerate",
        "qos": "1",
        "retain": "",
        "respTopic": "",
        "contentType": "",
        "userProps": "",
        "correl": "",
        "expiry": "",
        "broker": "6251341b8ce002d3",
        "x": 1150,
        "y": 2000,
        "wires": []
    },
    {
        "id": "29fb60c427c39f8e",
        "type": "mqtt out",
        "z": "b51fc0718e9c7f9d",
        "g": "bdbb4b2f64a2ea6f",
        "name": "",
        "topic": "Solar/veranda/command/power/set/activerate",
        "qos": "1",
        "retain": "",
        "respTopic": "",
        "contentType": "",
        "userProps": "",
        "correl": "",
        "expiry": "",
        "broker": "6251341b8ce002d3",
        "x": 1160,
        "y": 2060,
        "wires": []
    },
    {
        "id": "f187f03a749a140c",
        "type": "mqtt out",
        "z": "b51fc0718e9c7f9d",
        "g": "bdbb4b2f64a2ea6f",
        "name": "",
        "topic": "Solar/roof/command/power/set/activerate",
        "qos": "1",
        "retain": "",
        "respTopic": "",
        "contentType": "",
        "userProps": "",
        "correl": "",
        "expiry": "",
        "broker": "6251341b8ce002d3",
        "x": 1140,
        "y": 2120,
        "wires": []
    },
    {
        "id": "a1fa04f4896cc614",
        "type": "function",
        "z": "b51fc0718e9c7f9d",
        "g": "bdbb4b2f64a2ea6f",
        "name": "PV Control",
        "func": "// =====================\n// Context\n// =====================\nlet currentEnergyPrice = context.get('currentEnergyPrice');\nlet lastGridStatus = context.get('lastGridStatus');\nlet batterySOC = context.get('batterySOC');\nlet socOverrideActive = context.get('socOverrideActive') || false;\n\nlet minDischargeSOC = context.get('minDischargeSOC');          // NO fallback\nlet settingsReady = context.get('settingsReady') || false;     // gate\n\n// =====================\n// Nice status helper\n// =====================\nfunction setNiceStatus(color, title, lines = []) {\n    const text = [title, ...lines].join(\" | \");\n    node.status({ fill: color, shape: \"dot\", text });\n}\n\nfunction isFiniteNumber(v) {\n    const n = Number(v);\n    return Number.isFinite(n) ? n : null;\n}\n\n// =====================\n// VENUS SETTINGS (CONFIG INPUT)\n// - Stores minDischargeSOC\n// - If price is already known AND no grid-loss override is active,\n//   immediately applies PV control + throttling + targetSOC + pvShed.\n// =====================\nif (msg.topic === \"Venus settings - Minimum Discharge SOC (%)\") {\n    const value = isFiniteNumber(msg.payload);\n\n    if (value === null || value < 0 || value > 100) {\n        setNiceStatus(\"red\", \"❌ Invalid Venus Setting\", [\n            `Payload: ${msg.payload}`\n        ]);\n        return null;\n    }\n\n    minDischargeSOC = value;\n    context.set('minDischargeSOC', minDischargeSOC);\n\n    settingsReady = true;\n    context.set('settingsReady', true);\n\n    // If grid is lost and override is active, we only store the setting (don't change outputs)\n    if (lastGridStatus === 2 && socOverrideActive) {\n        setNiceStatus(\"blue\", \"⚙️ Settings Loaded\", [\n            `Min Discharge SOC: ${minDischargeSOC}%`,\n            \"Grid lost override active (no apply)\"\n        ]);\n        return null;\n    }\n\n    // Apply immediately only if price is known\n    const priceVal = isFiniteNumber(currentEnergyPrice);\n    if (priceVal === null) {\n        setNiceStatus(\"blue\", \"⚙️ Settings Loaded\", [\n            `Min Discharge SOC: ${minDischargeSOC}%`,\n            \"Waiting for price to apply\"\n        ]);\n        return null;\n    }\n\n    const isPositivePrice = priceVal > 0;\n    const pvOutputPercent = isPositivePrice ? 100 : 0;\n    const targetSOC = isPositivePrice ? minDischargeSOC : 100;\n    const throttling = !isPositivePrice;\n\n    setNiceStatus(isPositivePrice ? \"green\" : \"red\", \"⚙️ Settings Applied\", [\n        `Min SOC: ${minDischargeSOC}%`,\n        `Price: ${priceVal}`,\n        `PV: ${pvOutputPercent}%`,\n        `Target SOC: ${targetSOC}%`\n    ]);\n\n    return [[\n        { topic: \"pvControl\", payload: JSON.stringify({ value: pvOutputPercent }) },\n        { topic: \"throttling\", payload: throttling },\n        { topic: \"targetSOC\", payload: targetSOC },\n        { topic: \"pvShed\", payload: { power_on: isPositivePrice } },\n        {\n            topic: \"debug\",\n            payload: {\n                mode: \"venus_settings_update_apply\",\n                minDischargeSOC,\n                currentEnergyPrice: priceVal,\n                pvOutputPercent,\n                throttling,\n                targetSOC\n            }\n        }\n    ]];\n}\n\n// =====================\n// GATE: Do nothing until Venus settings are received\n// =====================\nif (!settingsReady || minDischargeSOC === undefined) {\n    setNiceStatus(\"yellow\", \"⏳ Waiting for Venus Settings\", [\n        \"Minimum Discharge SOC not received yet\"\n    ]);\n    return null;\n}\n\n// =====================\n// ENERGY PRICE HANDLING\n// =====================\nif (msg.topic === \"energyzero\") {\n    currentEnergyPrice = msg.payload;\n    context.set('currentEnergyPrice', currentEnergyPrice);\n\n    // If grid is lost and override is active, do not change PV based on price\n    if (lastGridStatus === 2 && socOverrideActive) return null;\n\n    const priceVal = isFiniteNumber(currentEnergyPrice);\n    if (priceVal === null) {\n        setNiceStatus(\"red\", \"❌ Price Invalid\", [`Payload: ${msg.payload}`]);\n        return null;\n    }\n\n    const isPositivePrice = priceVal > 0;\n    const pvOutputPercent = isPositivePrice ? 100 : 0;\n    const targetSOC = isPositivePrice ? minDischargeSOC : 100;\n    const throttling = !isPositivePrice;\n\n    setNiceStatus(isPositivePrice ? \"green\" : \"red\", \"⚡ Price Control\", [\n        `Price: ${priceVal}`,\n        `PV: ${pvOutputPercent}%`,\n        `Target SOC: ${targetSOC}%`,\n        `Min SOC: ${minDischargeSOC}%`\n    ]);\n\n    return [[\n        { topic: \"pvControl\", payload: JSON.stringify({ value: pvOutputPercent }) },\n        { topic: \"throttling\", payload: throttling },\n        { topic: \"targetSOC\", payload: targetSOC },\n        { topic: \"pvShed\", payload: { power_on: isPositivePrice } }, // PV Shed control\n        {\n            topic: \"debug\",\n            payload: {\n                mode: \"price_only_control\",\n                currentEnergyPrice: priceVal,\n                pvOutputPercent,\n                throttling,\n                targetSOC,\n                minDischargeSOC\n            }\n        }\n    ]];\n}\n\n// =====================\n// GRID LOSS ALARM HANDLING\n// =====================\nif (msg.topic === \"Multiplus-II - Grid - Grid lost alarm\") {\n    const alarmValue = msg.payload;\n    const wasGridLost = lastGridStatus === 2;\n\n    context.set('lastGridStatus', alarmValue);\n    lastGridStatus = alarmValue;\n\n    // ---- Grid lost ----\n    if (alarmValue === 2) {\n        if (batterySOC < 50) {\n            context.set('socOverrideActive', true);\n            socOverrideActive = true;\n\n            setNiceStatus(\"orange\", \"🚨 Emergency Override\", [\n                \"Grid Lost\",\n                `Battery: ${batterySOC}%`,\n                \"PV forced to 100%\"\n            ]);\n\n            return [[\n                { topic: \"pvControl\", payload: JSON.stringify({ value: 100 }) },\n                { topic: \"throttling\", payload: true },\n                { topic: \"pvShed\", payload: { power_on: true } }, // PV Shed ON during override\n                {\n                    topic: \"debug\",\n                    payload: {\n                        mode: \"grid_loss_soc_override\",\n                        batterySOC,\n                        pvOutputPercent: 100\n                    }\n                }\n            ]];\n        }\n\n        setNiceStatus(\"orange\", \"⚠️ Grid Lost\", [\n            `Battery: ${batterySOC}%`,\n            \"PV OFF\"\n        ]);\n\n        return [[\n            { topic: \"pvControl\", payload: JSON.stringify({ value: 0 }) },\n            { topic: \"throttling\", payload: true },\n            { topic: \"pvShed\", payload: { power_on: false } }, // PV Shed OFF on grid loss\n            {\n                topic: \"debug\",\n                payload: {\n                    mode: \"grid_loss\",\n                    batterySOC,\n                    pvOutputPercent: 0\n                }\n            }\n        ]];\n    }\n\n    // ---- Grid returned ----\n    if (alarmValue === 0 && wasGridLost) {\n        context.set('socOverrideActive', false);\n        socOverrideActive = false;\n\n        const priceVal = isFiniteNumber(currentEnergyPrice);\n        const priceKnown = priceVal !== null;\n\n        // If price unknown, we can't apply price-based control yet\n        if (!priceKnown) {\n            setNiceStatus(\"green\", \"✅ Grid Restored\", [\n                \"Price unknown (waiting)\",\n                `Min SOC: ${minDischargeSOC}%`\n            ]);\n            return null;\n        }\n\n        const isPositivePrice = priceVal > 0;\n        const pvOutputPercent = isPositivePrice ? 100 : 0;\n        const targetSOC = isPositivePrice ? minDischargeSOC : 100;\n        const throttling = !isPositivePrice;\n\n        setNiceStatus(isPositivePrice ? \"green\" : \"red\", \"✅ Grid Restored\", [\n            `Price: ${priceVal}`,\n            `PV: ${pvOutputPercent}%`,\n            `Target SOC: ${targetSOC}%`,\n            `Min SOC: ${minDischargeSOC}%`\n        ]);\n\n        return [[\n            { topic: \"pvControl\", payload: JSON.stringify({ value: pvOutputPercent }) },\n            { topic: \"throttling\", payload: throttling },\n            { topic: \"targetSOC\", payload: targetSOC },\n            { topic: \"pvShed\", payload: { power_on: isPositivePrice } }, // Resume PV Shed based on price\n            {\n                topic: \"debug\",\n                payload: {\n                    mode: \"grid_return_resume\",\n                    currentEnergyPrice: priceVal,\n                    pvOutputPercent,\n                    throttling,\n                    targetSOC,\n                    minDischargeSOC\n                }\n            }\n        ]];\n    }\n\n    return null;\n}\n\n// =====================\n// BATTERY SOC HANDLING\n// =====================\nif (msg.topic === \"Pylontech battery - State of charge (%)\") {\n    batterySOC = msg.payload;\n    context.set('batterySOC', batterySOC);\n\n    // Override ends when SOC recovers\n    if (socOverrideActive && batterySOC >= 90) {\n        context.set('socOverrideActive', false);\n        socOverrideActive = false;\n\n        setNiceStatus(\"orange\", \"🔋 SOC Recovered\", [\n            `Battery: ${batterySOC}%`,\n            \"Override cleared\",\n            \"PV OFF\"\n        ]);\n\n        return [[\n            { topic: \"pvControl\", payload: JSON.stringify({ value: 0 }) },\n            { topic: \"pvShed\", payload: { power_on: false } }, // Shed off when override ends\n            {\n                topic: \"debug\",\n                payload: {\n                    mode: \"soc_recovered\",\n                    batterySOC,\n                    pvOutputPercent: 0\n                }\n            }\n        ]];\n    }\n\n    // If grid lost and SOC low, activate override\n    if (!socOverrideActive && lastGridStatus === 2 && batterySOC < 50) {\n        context.set('socOverrideActive', true);\n        socOverrideActive = true;\n\n        setNiceStatus(\"orange\", \"🚨 Emergency Override\", [\n            \"Grid Lost\",\n            `Battery: ${batterySOC}%`,\n            \"PV forced to 100%\"\n        ]);\n\n        return [[\n            { topic: \"pvControl\", payload: JSON.stringify({ value: 100 }) },\n            { topic: \"pvShed\", payload: { power_on: true } }, // Shed on when override activates\n            {\n                topic: \"debug\",\n                payload: {\n                    mode: \"soc_low_grid_lost\",\n                    batterySOC,\n                    pvOutputPercent: 100\n                }\n            }\n        ]];\n    }\n\n    // Monitoring status (no outputs)\n    const priceVal = isFiniteNumber(currentEnergyPrice);\n    setNiceStatus(\"grey\", \"📊 Monitoring\", [\n        `Battery: ${batterySOC}%`,\n        `Grid: ${lastGridStatus === 2 ? \"Lost\" : \"OK\"}`,\n        `Min SOC: ${minDischargeSOC}%`,\n        `Price: ${priceVal === null ? \"n/a\" : priceVal}`\n    ]);\n\n    return null;\n}\n\n// =====================\n// DEFAULT\n// =====================\nreturn null;\n",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 770,
        "y": 1800,
        "wires": [
            [
                "03e042b6c49e7145"
            ]
        ]
    },
    {
        "id": "169dab67c8eecb5c",
        "type": "inject",
        "z": "b51fc0718e9c7f9d",
        "g": "bdbb4b2f64a2ea6f",
        "name": "Every 5 minutes",
        "props": [
            {
                "p": "payload"
            },
            {
                "p": "topic",
                "vt": "str"
            }
        ],
        "repeat": "300",
        "crontab": "",
        "once": true,
        "onceDelay": "5",
        "topic": "energyzero",
        "payload": "",
        "payloadType": "date",
        "x": 170,
        "y": 1700,
        "wires": [
            [
                "795f70d361eb852d"
            ]
        ]
    },
    {
        "id": "795f70d361eb852d",
        "type": "function",
        "z": "b51fc0718e9c7f9d",
        "g": "bdbb4b2f64a2ea6f",
        "name": "EnergyZero",
        "func": "let now = new Date();\nlet hour = now.getUTCHours().toString();\nlet url_dt_str = now.toISOString().slice(0, 13); // Format: YYYY-MM-DDTHH\nlet log_dt_str = now.toLocaleString('en-GB', { timeZone: 'UTC' }); // Format: DD/MM/YYYY HH:MM:SS\n\n// Log the fetch action\nnode.log(`${log_dt_str} Fetching tariff information`);\n\n// Construct the URL\nlet url = `https://api.energyzero.nl/v1/energyprices?fromDate=${url_dt_str}%3A00%3A00.000Z&tillDate=${now.toISOString().slice(0, 10)}T${hour}%3A59%3A59.999Z&interval=4&usageType=1&inclBtw=False`;\n\n// Pass the URL to the next node\nmsg.url = url;\nreturn msg;\n",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 390,
        "y": 1700,
        "wires": [
            [
                "e48a83570023d43f"
            ]
        ]
    },
    {
        "id": "e48a83570023d43f",
        "type": "http request",
        "z": "b51fc0718e9c7f9d",
        "g": "bdbb4b2f64a2ea6f",
        "name": "",
        "method": "GET",
        "ret": "txt",
        "paytoqs": "ignore",
        "url": "",
        "tls": "",
        "persist": false,
        "proxy": "",
        "insecureHTTPParser": false,
        "authType": "",
        "senderr": false,
        "headers": [],
        "x": 150,
        "y": 1760,
        "wires": [
            [
                "eb98fb36c0858bfc"
            ]
        ]
    },
    {
        "id": "eb98fb36c0858bfc",
        "type": "json",
        "z": "b51fc0718e9c7f9d",
        "g": "bdbb4b2f64a2ea6f",
        "name": "",
        "property": "payload",
        "action": "",
        "pretty": false,
        "x": 310,
        "y": 1760,
        "wires": [
            [
                "f9ecf63ba68d3e89"
            ]
        ]
    },
    {
        "id": "f9ecf63ba68d3e89",
        "type": "function",
        "z": "b51fc0718e9c7f9d",
        "g": "bdbb4b2f64a2ea6f",
        "name": "Format JSON",
        "func": "// Input: payload.Prices[0].price\nif (!msg.payload || !msg.payload.Prices || !msg.payload.Prices[0] || typeof msg.payload.Prices[0].price !== 'number') {\n    node.error(\"Invalid input. msg.payload.Prices[0].price must be a number.\");\n    node.status({ fill: \"red\", shape: \"ring\", text: \"Invalid input\" });\n    return null;\n}\n\nlet basePrice = msg.payload.Prices[0].price; // Base price\nlet purchaseCost = 0.028; // EnergyZero purchase cost\nlet tax = 0.09161; // Tax 2025: 0.10154 2026: 0.09161\n\n// Calculate final price\nlet finalPrice = (basePrice + purchaseCost + tax) * 1.21;\n\n// Set only the final price to the payload\nmsg.payload = finalPrice.toFixed(4);\n\n// Set the topic to \"energyzero\"\nmsg.topic = \"energyzero\";\n\n// Update node status with the calculated price\nnode.status({ fill: \"green\", shape: \"dot\", text: `€${msg.payload}` });\n\nreturn msg;\n",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 480,
        "y": 1760,
        "wires": [
            [
                "a1fa04f4896cc614",
                "41017fefc353bd5a"
            ]
        ]
    },
    {
        "id": "03e042b6c49e7145",
        "type": "switch",
        "z": "b51fc0718e9c7f9d",
        "g": "bdbb4b2f64a2ea6f",
        "name": "",
        "property": "topic",
        "propertyType": "msg",
        "rules": [
            {
                "t": "eq",
                "v": "throttling",
                "vt": "str"
            },
            {
                "t": "eq",
                "v": "pvShed",
                "vt": "str"
            },
            {
                "t": "eq",
                "v": "pvControl",
                "vt": "str"
            },
            {
                "t": "eq",
                "v": "targetSOC",
                "vt": "str"
            }
        ],
        "checkall": "true",
        "repair": false,
        "outputs": 4,
        "x": 750,
        "y": 2060,
        "wires": [
            [
                "7a1d5ae47e397e2e"
            ],
            [
                "e322d0801857ae34"
            ],
            [
                "e5c6a0996694e615",
                "29fb60c427c39f8e",
                "f187f03a749a140c"
            ],
            [
                "948625324d0ad0aa"
            ]
        ]
    },
    {
        "id": "7a1d5ae47e397e2e",
        "type": "link out",
        "z": "b51fc0718e9c7f9d",
        "g": "bdbb4b2f64a2ea6f",
        "name": "Throttle",
        "mode": "link",
        "links": [
            "2ef59f8dc6aeadc0",
            "5313d8a762e80b1a",
            "96e6aa7745635e58"
        ],
        "x": 1005,
        "y": 1880,
        "wires": []
    },
    {
        "id": "2d3733302885b499",
        "type": "victron-output-settings",
        "z": "b51fc0718e9c7f9d",
        "g": "bdbb4b2f64a2ea6f",
        "service": "com.victronenergy.settings",
        "path": "/Settings/CGwacs/BatteryLife/MinimumSocLimit",
        "serviceObj": {
            "service": "com.victronenergy.settings",
            "name": "Venus settings"
        },
        "pathObj": {
            "path": "/Settings/CGwacs/BatteryLife/MinimumSocLimit",
            "type": "float",
            "name": "ESS Minimum SoC (unless grid fails) (%)",
            "mode": "both"
        },
        "name": "",
        "onlyChanges": false,
        "x": 1190,
        "y": 2180,
        "wires": []
    },
    {
        "id": "d36b13b7e53900e3",
        "type": "victron-input-vebus",
        "z": "b51fc0718e9c7f9d",
        "g": "bdbb4b2f64a2ea6f",
        "service": "com.victronenergy.vebus/276",
        "path": "/Alarms/GridLost",
        "serviceObj": {
            "service": "com.victronenergy.vebus/276",
            "name": "Multiplus-II - Grid"
        },
        "pathObj": {
            "path": "/Alarms/GridLost",
            "type": "enum",
            "name": "Grid lost alarm",
            "enum": {
                "0": "Ok",
                "2": "Alarm"
            }
        },
        "initial": "",
        "name": "",
        "onlyChanges": false,
        "x": 260,
        "y": 1840,
        "wires": [
            [
                "a1fa04f4896cc614"
            ]
        ]
    },
    {
        "id": "e322d0801857ae34",
        "type": "http request",
        "z": "b51fc0718e9c7f9d",
        "g": "bdbb4b2f64a2ea6f",
        "name": "Change PV Shed Status",
        "method": "PUT",
        "ret": "txt",
        "paytoqs": "ignore",
        "url": "http://192.168.88.23/api/v1/state",
        "tls": "",
        "persist": false,
        "proxy": "",
        "insecureHTTPParser": false,
        "authType": "",
        "senderr": false,
        "headers": [
            {
                "keyType": "Content-Type",
                "keyValue": "",
                "valueType": "application/json",
                "valueValue": ""
            }
        ],
        "x": 1090,
        "y": 1940,
        "wires": [
            []
        ]
    },
    {
        "id": "41017fefc353bd5a",
        "type": "function",
        "z": "b51fc0718e9c7f9d",
        "d": true,
        "g": "bdbb4b2f64a2ea6f",
        "name": "send markdown",
        "func": "var message = \"Prijs kWh: \" + msg.payload + \"\";\nmsg.payload = {chatId : -xxxxx, type : 'message', content : message};\n\n// activate markdown\nmsg.payload.options = {disable_web_page_preview : true, parse_mode : \"Markdown\"};\nreturn msg;",
        "outputs": 1,
        "timeout": "",
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 840,
        "y": 1700,
        "wires": [
            [
                "927efb2a9b581949"
            ]
        ]
    },
    {
        "id": "b2986e48ca7cc4d9",
        "type": "link out",
        "z": "b51fc0718e9c7f9d",
        "d": true,
        "g": "bdbb4b2f64a2ea6f",
        "name": "Telegram",
        "mode": "link",
        "links": [
            "1cc41c3726114c61"
        ],
        "x": 1165,
        "y": 1700,
        "wires": []
    },
    {
        "id": "927efb2a9b581949",
        "type": "delay",
        "z": "b51fc0718e9c7f9d",
        "d": true,
        "g": "bdbb4b2f64a2ea6f",
        "name": "",
        "pauseType": "rate",
        "timeout": "5",
        "timeoutUnits": "minutes",
        "rate": "1",
        "nbRateUnits": "1",
        "rateUnits": "hour",
        "randomFirst": "1",
        "randomLast": "5",
        "randomUnits": "seconds",
        "drop": true,
        "allowrate": false,
        "outputs": 1,
        "x": 1050,
        "y": 1700,
        "wires": [
            [
                "b2986e48ca7cc4d9"
            ]
        ]
    },
    {
        "id": "4986f56544299c23",
        "type": "victron-input-ess",
        "z": "b51fc0718e9c7f9d",
        "g": "bdbb4b2f64a2ea6f",
        "service": "com.victronenergy.settings",
        "path": "/Settings/CGwacs/BatteryLife/MinimumSocLimit",
        "serviceObj": {
            "service": "com.victronenergy.settings",
            "name": "Venus settings"
        },
        "pathObj": {
            "path": "/Settings/CGwacs/BatteryLife/MinimumSocLimit",
            "type": "integer",
            "name": "Minimum Discharge SOC (%)"
        },
        "name": "",
        "onlyChanges": false,
        "x": 230,
        "y": 1960,
        "wires": [
            [
                "a1fa04f4896cc614"
            ]
        ]
    },
    {
        "id": "948625324d0ad0aa",
        "type": "rbe",
        "z": "b51fc0718e9c7f9d",
        "g": "bdbb4b2f64a2ea6f",
        "name": "At change only",
        "func": "rbe",
        "gap": "",
        "start": "",
        "inout": "out",
        "septopics": true,
        "property": "payload",
        "topi": "topic",
        "x": 860,
        "y": 2180,
        "wires": [
            [
                "2d3733302885b499"
            ]
        ]
    },
    {
        "id": "3aecf133104345cb",
        "type": "victron-input-battery",
        "z": "b51fc0718e9c7f9d",
        "g": "bdbb4b2f64a2ea6f",
        "service": "com.victronenergy.battery/512",
        "path": "/Soc",
        "serviceObj": {
            "service": "com.victronenergy.battery/512",
            "name": "Pylontech battery"
        },
        "pathObj": {
            "path": "/Soc",
            "type": "float",
            "name": "State of charge (%)"
        },
        "initial": "",
        "name": "",
        "onlyChanges": false,
        "x": 250,
        "y": 1900,
        "wires": [
            [
                "a1fa04f4896cc614"
            ]
        ]
    },
    {
        "id": "6251341b8ce002d3",
        "type": "mqtt-broker",
        "name": "venus.local",
        "broker": "192.168.88.20",
        "port": "1883",
        "clientid": "",
        "autoConnect": true,
        "usetls": false,
        "protocolVersion": "4",
        "keepalive": "60",
        "cleansession": true,
        "autoUnsubscribe": true,
        "birthTopic": "",
        "birthQos": "0",
        "birthRetain": "false",
        "birthPayload": "",
        "birthMsg": {},
        "closeTopic": "",
        "closeQos": "0",
        "closeRetain": "false",
        "closePayload": "",
        "closeMsg": {},
        "willTopic": "",
        "willQos": "0",
        "willRetain": "false",
        "willPayload": "",
        "willMsg": {},
        "userProps": "",
        "sessionExpiry": ""
    },
    {
        "id": "7b1362bd84451d1c",
        "type": "global-config",
        "env": [],
        "modules": {
            "@victronenergy/node-red-contrib-victron": "1.6.56"
        }
    }
]
[
    {
        "id": "8658e94b65d3a6be",
        "type": "group",
        "z": "b51fc0718e9c7f9d",
        "style": {
            "stroke": "#999999",
            "stroke-opacity": "1",
            "fill": "none",
            "fill-opacity": "1",
            "label": true,
            "label-position": "nw",
            "color": "#a4a4a4"
        },
        "nodes": [
            "5001faaa4e3d77bc",
            "c73a421597f55a1f",
            "396f9c9de0c55b20",
            "2b6cc1c190f73a88",
            "0e8d46730e00ffa7",
            "1c6f1aa4b23094cd",
            "f1fb6101f06e5965",
            "b8db983e52530d6a",
            "efc1cd5755471669",
            "a6a5dd7d547fca63",
            "35781d9a72c87286",
            "4ee6f205806e4e53",
            "284233453c55723a",
            "c4bbe0efe71ff812"
        ],
        "x": 34,
        "y": 899,
        "w": 1272,
        "h": 242
    },
    {
        "id": "5001faaa4e3d77bc",
        "type": "victron-virtual",
        "z": "b51fc0718e9c7f9d",
        "g": "8658e94b65d3a6be",
        "name": "PV Shed",
        "outputs": 1,
        "device": "pvinverter",
        "default_values": false,
        "enable_s2support": false,
        "battery_capacity": 25,
        "include_battery_temperature": false,
        "battery_voltage_custom": "",
        "include_engine_hours": false,
        "include_starter_voltage": false,
        "include_history_energy": false,
        "grid_nrofphases": 1,
        "include_motor_temp": false,
        "include_controller_temp": false,
        "include_coolant_temp": false,
        "include_motor_rpm": true,
        "include_motor_direction": true,
        "position": "1",
        "pvinverter_nrofphases": 1,
        "fluid_type": 0,
        "include_tank_battery": false,
        "include_tank_temperature": false,
        "tank_battery_voltage": 3.3,
        "tank_capacity": 0.2,
        "temperature_type": 2,
        "include_humidity": false,
        "include_pressure": false,
        "include_temp_battery": false,
        "temp_battery_voltage": 3.3,
        "x": 1000,
        "y": 980,
        "wires": [
            [
                "284233453c55723a"
            ]
        ]
    },
    {
        "id": "c73a421597f55a1f",
        "type": "inject",
        "z": "b51fc0718e9c7f9d",
        "g": "8658e94b65d3a6be",
        "name": "",
        "props": [
            {
                "p": "payload"
            },
            {
                "p": "topic",
                "vt": "str"
            }
        ],
        "repeat": "",
        "crontab": "",
        "once": true,
        "onceDelay": "5",
        "topic": "/Ac/MaxPower",
        "payload": "0.8",
        "payloadType": "num",
        "x": 450,
        "y": 940,
        "wires": [
            [
                "4ee6f205806e4e53"
            ]
        ]
    },
    {
        "id": "396f9c9de0c55b20",
        "type": "delay",
        "z": "b51fc0718e9c7f9d",
        "g": "8658e94b65d3a6be",
        "name": "",
        "pauseType": "rate",
        "timeout": "5",
        "timeoutUnits": "minutes",
        "rate": "1",
        "nbRateUnits": "1",
        "rateUnits": "minute",
        "randomFirst": "1",
        "randomLast": "5",
        "randomUnits": "seconds",
        "drop": true,
        "allowrate": false,
        "outputs": 1,
        "x": 700,
        "y": 1040,
        "wires": [
            [
                "2b6cc1c190f73a88"
            ]
        ]
    },
    {
        "id": "2b6cc1c190f73a88",
        "type": "function",
        "z": "b51fc0718e9c7f9d",
        "g": "8658e94b65d3a6be",
        "name": "PV Output",
        "func": "var d = moment().format(\"YYYYMMDD\");\nvar t = moment().format(\"HH:mm\");\nvar v2 = msg.payload;\n\n// Set v2 to 0 if msg.payload is negative\nif (v2 < 0) {\n    v2 = 0;\n}\n\nmsg.payload = {\n    d: d,\n    t: t,\n    v2: v2\n}\nmsg.action = msg.payload\nmsg.headers = { \n    'X-Pvoutput-Apikey': 'xxxx',\n    'X-Pvoutput-SystemId': 'xxxx',\n    'Content-Type': 'application/x-www-form-urlencoded'\n};\nmsg.url = \"http://pvoutput.org/service/r2/addstatus.jsp\";\nreturn msg;\n\n",
        "outputs": 1,
        "timeout": "",
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [
            {
                "var": "moment",
                "module": "moment"
            }
        ],
        "x": 870,
        "y": 1040,
        "wires": [
            [
                "0e8d46730e00ffa7"
            ]
        ]
    },
    {
        "id": "0e8d46730e00ffa7",
        "type": "http request",
        "z": "b51fc0718e9c7f9d",
        "g": "8658e94b65d3a6be",
        "name": "Post",
        "method": "POST",
        "ret": "txt",
        "paytoqs": "ignore",
        "url": "",
        "tls": "",
        "persist": false,
        "proxy": "",
        "insecureHTTPParser": false,
        "authType": "",
        "senderr": false,
        "headers": [],
        "x": 1010,
        "y": 1040,
        "wires": [
            [
                "1c6f1aa4b23094cd"
            ]
        ]
    },
    {
        "id": "1c6f1aa4b23094cd",
        "type": "debug",
        "z": "b51fc0718e9c7f9d",
        "g": "8658e94b65d3a6be",
        "name": "debug 8",
        "active": false,
        "tosidebar": true,
        "console": false,
        "tostatus": true,
        "complete": "payload",
        "targetType": "msg",
        "statusVal": "payload",
        "statusType": "msg",
        "x": 1200,
        "y": 1040,
        "wires": []
    },
    {
        "id": "f1fb6101f06e5965",
        "type": "inject",
        "z": "b51fc0718e9c7f9d",
        "g": "8658e94b65d3a6be",
        "name": "",
        "props": [
            {
                "p": "payload"
            }
        ],
        "repeat": "5",
        "crontab": "",
        "once": false,
        "onceDelay": 0.1,
        "topic": "",
        "payload": "",
        "payloadType": "date",
        "x": 150,
        "y": 980,
        "wires": [
            [
                "a6a5dd7d547fca63"
            ]
        ]
    },
    {
        "id": "b8db983e52530d6a",
        "type": "inject",
        "z": "b51fc0718e9c7f9d",
        "g": "8658e94b65d3a6be",
        "name": "",
        "props": [
            {
                "p": "payload"
            }
        ],
        "repeat": "",
        "crontab": "",
        "once": true,
        "onceDelay": "1",
        "topic": "",
        "payload": "",
        "payloadType": "date",
        "x": 150,
        "y": 1020,
        "wires": [
            [
                "efc1cd5755471669"
            ]
        ]
    },
    {
        "id": "efc1cd5755471669",
        "type": "http request",
        "z": "b51fc0718e9c7f9d",
        "g": "8658e94b65d3a6be",
        "name": "PV - HomeWizard Information",
        "method": "GET",
        "ret": "obj",
        "paytoqs": "ignore",
        "url": "http://192.168.88.23/api",
        "tls": "",
        "persist": false,
        "proxy": "",
        "insecureHTTPParser": false,
        "authType": "",
        "senderr": false,
        "headers": [],
        "x": 410,
        "y": 1020,
        "wires": [
            [
                "4ee6f205806e4e53"
            ]
        ]
    },
    {
        "id": "a6a5dd7d547fca63",
        "type": "http request",
        "z": "b51fc0718e9c7f9d",
        "g": "8658e94b65d3a6be",
        "name": "PV - HomeWizard Measurements",
        "method": "GET",
        "ret": "obj",
        "paytoqs": "ignore",
        "url": "http://192.168.88.23/api/v1/data",
        "tls": "",
        "persist": false,
        "proxy": "",
        "insecureHTTPParser": false,
        "authType": "",
        "senderr": false,
        "headers": [],
        "x": 400,
        "y": 980,
        "wires": [
            [
                "4ee6f205806e4e53"
            ]
        ]
    },
    {
        "id": "35781d9a72c87286",
        "type": "inject",
        "z": "b51fc0718e9c7f9d",
        "g": "8658e94b65d3a6be",
        "name": "",
        "props": [
            {
                "p": "payload"
            },
            {
                "p": "topic",
                "vt": "str"
            }
        ],
        "repeat": "",
        "crontab": "",
        "once": true,
        "onceDelay": "1",
        "topic": "throttling",
        "payload": "false",
        "payloadType": "str",
        "x": 460,
        "y": 1060,
        "wires": [
            [
                "4ee6f205806e4e53"
            ]
        ]
    },
    {
        "id": "4ee6f205806e4e53",
        "type": "function",
        "z": "b51fc0718e9c7f9d",
        "g": "8658e94b65d3a6be",
        "name": "Parse & Route PV Data",
        "func": "const data = msg.payload;\n\nlet payload = {};\nlet statusCode = null;\n\n// --- Throttling (optional): if you send msg.topic=\"throttling\" ---\nif (msg.topic === \"throttling\" && (data === true || data === false)) {\n    context.set(\"isThrottling\", data);\n    return null;\n}\nconst isThrottling = context.get(\"isThrottling\") === true;\nif (isThrottling) statusCode = 12;\n\n// --- One-time inject: /Ac/MaxPower ---\nif (msg.topic === \"/Ac/MaxPower\") {\n    payload[\"/Ac/MaxPower\"] = Number(data);\n    msg.payload = payload;\n    return msg;\n}\n\n// --- Determine phase mode from product_type (when available) ---\n// HWE-KWH3 => 3-phase\n// HWE-KWH1, HWE-SKT, unknown => 1-phase\nlet phaseMode = null; // 1 or 3\n\nif (typeof data === \"object\" && data && data.product_type) {\n    payload[\"/ProductName\"] = String(data.product_type);\n    if (data.serial) payload[\"/Serial\"] = String(data.serial);\n\n    if (data.product_type === \"HWE-KWH3\") phaseMode = 3;\n    else phaseMode = 1; // includes HWE-KWH1, HWE-SKT, and any unknown types\n\n    // Save mode so measurement messages can use it\n    context.set(\"phaseMode\", phaseMode);\n\n    msg.payload = payload;\n    return msg;\n}\n\n// If no product_type in this msg, fall back to last known mode (default 1)\nphaseMode = context.get(\"phaseMode\") || 1;\n\n// --- Measurement handling ---\nif (typeof data === \"object\" && data && (\"wifi_ssid\" in data)) {\n\n    if (phaseMode === 3) {\n        // Voltage\n        payload[\"/Ac/L1/Voltage\"] = Number(data.active_voltage_l1_v);\n        payload[\"/Ac/L2/Voltage\"] = Number(data.active_voltage_l2_v);\n        payload[\"/Ac/L3/Voltage\"] = Number(data.active_voltage_l3_v);\n\n        // Current\n        payload[\"/Ac/L1/Current\"] = Number(data.active_current_l1_a);\n        payload[\"/Ac/L2/Current\"] = Number(data.active_current_l2_a);\n        payload[\"/Ac/L3/Current\"] = Number(data.active_current_l3_a);\n\n        // Power (negated for PV export display)\n        payload[\"/Ac/L1/Power\"] = -1 * Number(data.active_power_l1_w);\n        payload[\"/Ac/L2/Power\"] = -1 * Number(data.active_power_l2_w);\n        payload[\"/Ac/L3/Power\"] = -1 * Number(data.active_power_l3_w);\n        payload[\"/Ac/Power\"]    = -1 * Number(data.active_power_w);\n\n        // Energy\n        if (\"total_power_export_kwh\" in data) {\n            const total = Number(data.total_power_export_kwh);\n            payload[\"/Ac/Energy/Forward\"] = total;\n\n            const perPhase = total / 3;\n            payload[\"/Ac/L1/Energy/Forward\"] = perPhase;\n            payload[\"/Ac/L2/Energy/Forward\"] = perPhase;\n            payload[\"/Ac/L3/Energy/Forward\"] = perPhase;\n        }\n\n        // StatusCode fallback (if not throttling)\n        if (statusCode === null) {\n            statusCode = Math.abs(Number(data.active_power_w)) > 0 ? 7 : 8;\n        }\n\n    } else {\n        // 1-phase (L1 only)\n        payload[\"/Ac/L1/Voltage\"] = Number(data.active_voltage_v);\n        payload[\"/Ac/L1/Current\"] = Number(data.active_current_a);\n\n        // Power (negated for PV export display)\n        payload[\"/Ac/L1/Power\"] = -1 * Number(data.active_power_l1_w);\n        payload[\"/Ac/Power\"]    = -1 * Number(data.active_power_w);\n\n        // Energy\n        if (\"total_power_export_kwh\" in data) {\n            const total = Number(data.total_power_export_kwh);\n            payload[\"/Ac/Energy/Forward\"]    = total;\n            payload[\"/Ac/L1/Energy/Forward\"] = total;\n        }\n\n        // StatusCode fallback (if not throttling)\n        if (statusCode === null) {\n            statusCode = Math.abs(Number(data.active_power_w)) > 0 ? 7 : 8;\n        }\n    }\n\n    if (statusCode !== null) payload[\"/StatusCode\"] = statusCode;\n\n    msg.payload = payload;\n    return msg;\n}\n\nreturn null;\n",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 730,
        "y": 980,
        "wires": [
            [
                "5001faaa4e3d77bc",
                "396f9c9de0c55b20"
            ]
        ]
    },
    {
        "id": "284233453c55723a",
        "type": "debug",
        "z": "b51fc0718e9c7f9d",
        "g": "8658e94b65d3a6be",
        "name": "debug 10",
        "active": false,
        "tosidebar": true,
        "console": false,
        "tostatus": false,
        "complete": "false",
        "statusVal": "",
        "statusType": "auto",
        "x": 1200,
        "y": 980,
        "wires": []
    },
    {
        "id": "c4bbe0efe71ff812",
        "type": "link in",
        "z": "b51fc0718e9c7f9d",
        "g": "8658e94b65d3a6be",
        "name": "Throttle",
        "links": [
            "7a1d5ae47e397e2e"
        ],
        "x": 525,
        "y": 1100,
        "wires": [
            [
                "4ee6f205806e4e53"
            ]
        ]
    },
    {
        "id": "79453a14912c5c0f",
        "type": "global-config",
        "env": [],
        "modules": {
            "@victronenergy/node-red-contrib-victron": "1.6.56"
        }
    }
]
1 Like