This Node-RED flow exposes multiple Growatt inverters as local SunSpec Modbus TCP devices using MQTT data as the source.
The setup was created to integrate Growatt inverters into systems that expect standard SunSpec Modbus registers, while the actual inverter telemetry is received through MQTT.
This implementation is based on the excellent work and ideas discussed here. Thanks @JeroenSt! :
The flow currently supports:
-
One three-phase inverter using SunSpec Model 103
-
Two single-phase inverters using SunSpec Model 101
Each inverter is exposed as its own local Modbus TCP server.
Features
MQTT to SunSpec conversion
The flow subscribes to inverter MQTT topics and converts the incoming Growatt JSON payloads into standard SunSpec registers.
Mapped values include:
-
AC power
-
AC voltage
-
AC current
-
Grid frequency
-
DC voltage/current/power
-
Inverter temperature
-
Lifetime energy
-
Operating state
Separate Modbus TCP servers
Each inverter gets its own local Modbus TCP endpoint.
Example:
-
Inverter 1 → localhost:5021
-
Inverter 2 → localhost:5022
-
Inverter 3 → localhost:5023
This makes the inverters appear as independent SunSpec-compatible devices.
SunSpec Models
The flow initializes:
-
SunSpec Common Model
-
Inverter Model 101 or 103
-
Nameplate Model 120
-
Controls Model 123
Offline watchdog
A watchdog monitors MQTT activity.
If valid data stops arriving for more than 120 seconds:
-
the inverter is marked offline
-
live values are set to zero
-
the SunSpec status changes to Off
The last valid lifetime energy counter is preserved.
Invalid JSON protection
Malformed MQTT payloads are ignored and do not immediately trigger offline status.
Active power limit control
The flow also supports active power limit control through SunSpec register 155 (WMaxLimPct).
When the register value changes, the flow publishes MQTT commands back to the inverter control topic.
You can add something like this below the download link:
Before using this flow
You will need to adjust several settings to match your own environment.
1. MQTT broker settings
Edit the MQTT broker node and change:
-
Broker IP / hostname
-
Port
-
Authentication settings if required
2. MQTT topics
Update the MQTT topics to match your own inverter telemetry topics.
Example:
Solar/inverter1
Solar/inverter2
Solar/inverter3
You must also update the command topics if you use active power limiting.
3. Inverter configuration
Inside the function nodes, adjust:
-
Inverter names
-
Inverter models
-
Maximum power ratings
-
Phase type (single-phase or three-phase)
Example:
veranda: { output: 0, phase: 3, model: "MOD 4000TL3-X", wrtg: 4000 }
4. Serial numbers and metadata
The example serial numbers are placeholders.
Replace:
-
Serial numbers
-
Firmware versions
-
Manufacturer information if needed
5. Modbus TCP ports
By default the flow exposes:
5021
5022
5023
Make sure these ports are free on your system.
6. MQTT payload structure
This flow expects Growatt-style MQTT JSON payloads containing fields such as:
OutputPower
GridFrequency
PV1Voltage
PV1InputCurrent
TotalGenerateEnergy
InverterTemperature
If your MQTT payload format differs, you must adjust the mapping functions.
7. Active power limit control
The flow supports active power limiting through SunSpec register 155.
To make this work:
-
connect the MQTT output node correctly
-
verify your inverter accepts MQTT control commands
-
verify the command topic format
8. Watchdog timeout
Default timeout is:
120 seconds
You can adjust this inside the watchdog section of the main function node.
Final note
The MQTT topics and JSON payload structure used in this flow are designed around the MQTT implementation from:
If you use a different MQTT source or another Growatt integration, the payload field names and topic structure may differ.
In that case you will need to modify:
-
MQTT topic subscriptions
-
MQTT command topics
-
JSON field mappings inside the function nodes
Especially the live SunSpec mapping logic depends heavily on the expected OpenInverterGateway payload format.
[
{
"id": "flow_sunspec_mqtt_growatt",
"type": "tab",
"label": "SunSpec MQTT Growatt Inverters",
"disabled": false,
"info": "Example flow: multiple Growatt inverters exposed as SunSpec Modbus TCP servers. Replace MQTT broker, topics, serial numbers, ports and inverter details with your own values."
},
{
"id": "mqtt_in_solar",
"type": "mqtt in",
"z": "flow_sunspec_mqtt_growatt",
"name": "MQTT Solar/+",
"topic": "Solar/+",
"qos": "0",
"datatype": "auto-detect",
"broker": "mqtt_broker_config",
"nl": false,
"rap": true,
"rh": 0,
"inputs": 0,
"x": 140,
"y": 120,
"wires": [
[
"json_parse_solar"
]
]
},
{
"id": "json_parse_solar",
"type": "json",
"z": "flow_sunspec_mqtt_growatt",
"name": "Parse JSON",
"property": "payload",
"action": "",
"pretty": false,
"x": 330,
"y": 120,
"wires": [
[
"fn_live_registers"
]
]
},
{
"id": "fn_live_registers",
"type": "function",
"z": "flow_sunspec_mqtt_growatt",
"name": "Growatt MQTT to SunSpec live registers",
"func": "let d;\nlet forcedOffline = false;\nlet watchdogTick = false;\nlet invalidJson = false;\n\n// A periodic inject can trigger the watchdog.\n// Configure the inject node with msg.topic = \"watchdog\".\nif (msg.topic === \"watchdog\") {\n watchdogTick = true;\n} else {\n try {\n if (\n msg.payload === undefined ||\n msg.payload === null ||\n msg.payload === \"\" ||\n (typeof msg.payload === \"object\" && Object.keys(msg.payload).length === 0)\n ) {\n d = { InverterStatus: -1 };\n forcedOffline = true;\n } else {\n d = typeof msg.payload === \"string\" ? JSON.parse(msg.payload) : msg.payload;\n }\n } catch (err) {\n invalidJson = true;\n }\n}\n\nconst inverterConfigs = {\n inverter1: { output: 0, phase: 3, model: \"THREE_PHASE_INVERTER_MODEL\", wrtg: 4000 },\n inverter2: { output: 1, phase: 1, model: \"SINGLE_PHASE_INVERTER_MODEL_A\", wrtg: 2500 },\n inverter3: { output: 2, phase: 1, model: \"SINGLE_PHASE_INVERTER_MODEL_B\", wrtg: 1500 }\n};\n\n// If JSON is invalid, ignore the message completely.\n// Do not mark the inverter offline and do not refresh last_seen.\n// The watchdog will mark it offline later if valid data stops arriving.\nif (invalidJson) {\n node.status({\n fill: \"yellow\",\n shape: \"ring\",\n text: `Ignored invalid JSON on ${msg.topic || \"unknown topic\"}`\n });\n return [null, null, null];\n}\n\nfunction n(value, fallback = 0) {\n const parsed = Number(value);\n return Number.isFinite(parsed) ? parsed : fallback;\n}\n\nfunction sf(value) {\n return value < 0 ? 0x10000 + value : value;\n}\n\nfunction splitU32(value) {\n const v = Math.max(0, Math.round(value)) >>> 0;\n return [(v >>> 16) & 0xFFFF, v & 0xFFFF];\n}\n\nfunction hasUsefulPayload(d) {\n if (!d || typeof d !== \"object\") return false;\n if (n(d.InverterStatus) === -1) return false;\n\n return (\n d.OutputPower !== undefined ||\n d.InputPower !== undefined ||\n d.GridFrequency !== undefined ||\n d.TotalGenerateEnergy !== undefined ||\n d.L1ThreePhaseGridVoltage !== undefined ||\n d.PV1Voltage !== undefined\n );\n}\n\n// SunSpec inverter status register St.\n// Known / inferred mapping from Growatt MQTT payloads:\n// 1 = Off\n// 4 = MPPT / running\n// 5 = Throttled, inferred when ActivePowerRate < 100\n// 8 = Standby\nfunction sunspecStatus(d, offline, acPower) {\n const inverterStatus = n(d.InverterStatus);\n const activePowerRate = n(d.ActivePowerRate, 100);\n\n if (offline || inverterStatus === -1) {\n return 1; // Off\n }\n\n if (inverterStatus === 1 && acPower > 0 && activePowerRate < 100) {\n return 5; // Throttled, inferred from active power limit\n }\n\n if (inverterStatus === 1 && acPower > 0) {\n return 4; // MPPT / running\n }\n\n if (inverterStatus === 1 && acPower === 0) {\n return 8; // Standby\n }\n\n return 8; // Unknown online state\n}\n\nfunction setCommonLive(regs, name, d, acPower, acFreq, acEnergyKwh, dcVoltage, dcCurrent, dcPower, temp, offline) {\n const key = `${name}_last_wh`;\n let wh;\n\n if (offline) {\n wh = flow.get(key) || 0;\n } else {\n wh = n(acEnergyKwh) * 1000;\n if (wh > 0) flow.set(key, wh);\n }\n\n regs[16] = Math.round(acPower);\n regs[17] = 0;\n\n regs[18] = Math.round(acFreq * 100);\n regs[19] = sf(-2);\n\n const [whHi, whLo] = splitU32(wh);\n regs[26] = whHi;\n regs[27] = whLo;\n regs[28] = 0;\n\n regs[29] = Math.round(dcCurrent * 100);\n regs[30] = sf(-2);\n\n regs[31] = Math.round(dcVoltage);\n regs[32] = 0;\n\n regs[33] = Math.round(dcPower);\n regs[34] = 0;\n\n regs[35] = Math.round(temp * 10);\n regs[39] = sf(-1);\n\n regs[40] = sunspecStatus(d, offline, acPower);\n\n regs[42] = 0;\n regs[43] = 0;\n regs[44] = 0;\n regs[45] = 0;\n}\n\nfunction buildModel101(name, d, offline) {\n const regs = new Array(46).fill(0xFFFF);\n\n regs[0] = 1;\n regs[1] = 0xFFFF;\n regs[2] = 101;\n regs[3] = 50;\n\n const acPower = offline ? 0 : n(d.OutputPower);\n const acVoltage = offline ? 0 : n(d.L1ThreePhaseGridVoltage);\n const acCurrent = offline ? 0 : n(d.L1ThreePhaseGridOutputCurrent);\n const acFreq = offline ? 0 : n(d.GridFrequency);\n const acEnergyKwh = n(d.TotalGenerateEnergy);\n\n const dcPower = offline ? 0 : (n(d.PV1InputPower) || n(d.InputPower));\n const dcVoltage = offline ? 0 : n(d.PV1Voltage);\n const dcCurrent = offline ? 0 : n(d.PV1InputCurrent);\n const temp = offline ? 0 : n(d.InverterTemperature);\n\n regs[4] = Math.round(acCurrent * 100);\n regs[5] = Math.round(acCurrent * 100);\n regs[8] = sf(-2);\n\n regs[12] = Math.round(acVoltage);\n regs[15] = 0;\n\n setCommonLive(\n regs,\n name,\n d,\n acPower,\n acFreq,\n acEnergyKwh,\n dcVoltage,\n dcCurrent,\n dcPower,\n temp,\n offline\n );\n\n return regs;\n}\n\nfunction buildModel103(name, d, offline) {\n const regs = new Array(46).fill(0xFFFF);\n\n regs[0] = 1;\n regs[1] = 0xFFFF;\n regs[2] = 103;\n regs[3] = 50;\n\n const l1v = offline ? 0 : n(d.L1ThreePhaseGridVoltage);\n const l2v = offline ? 0 : n(d.L2ThreePhaseGridVoltage);\n const l3v = offline ? 0 : n(d.L3ThreePhaseGridVoltage);\n\n const l1a = offline ? 0 : n(d.L1ThreePhaseGridOutputCurrent);\n const l2a = offline ? 0 : n(d.L2ThreePhaseGridOutputCurrent);\n const l3a = offline ? 0 : n(d.L3ThreePhaseGridOutputCurrent);\n\n const acPower = offline ? 0 : n(d.OutputPower);\n const acFreq = offline ? 0 : n(d.GridFrequency);\n const acEnergyKwh = n(d.TotalGenerateEnergy);\n\n const dcPower = offline ? 0 : n(d.InputPower);\n const dcVoltage = offline ? 0 : n(d.PV1Voltage);\n const dcCurrent = offline ? 0 : n(d.PV1InputCurrent);\n const temp = offline ? 0 : n(d.InverterTemperature);\n\n regs[4] = Math.round((l1a + l2a + l3a) * 100);\n regs[5] = Math.round(l1a * 100);\n regs[6] = Math.round(l2a * 100);\n regs[7] = Math.round(l3a * 100);\n regs[8] = sf(-2);\n\n // MQTT gives phase-neutral voltages.\n // SunSpec Model 103 also has line-line voltage fields, so these are estimated.\n regs[9] = Math.round(((l1v + l2v) / 2) * Math.sqrt(3));\n regs[10] = Math.round(((l2v + l3v) / 2) * Math.sqrt(3));\n regs[11] = Math.round(((l3v + l1v) / 2) * Math.sqrt(3));\n\n regs[12] = Math.round(l1v);\n regs[13] = Math.round(l2v);\n regs[14] = Math.round(l3v);\n regs[15] = 0;\n\n setCommonLive(\n regs,\n name,\n d,\n acPower,\n acFreq,\n acEnergyKwh,\n dcVoltage,\n dcCurrent,\n dcPower,\n temp,\n offline\n );\n\n return regs;\n}\n\nfunction buildOutputForInverter(name, d, offline) {\n const cfg = inverterConfigs[name];\n\n const regs = cfg.phase === 3\n ? buildModel103(name, d, offline)\n : buildModel101(name, d, offline);\n\n return {\n payload: {\n value: regs,\n fc: 16,\n unitid: 1,\n address: 68,\n quantity: regs.length\n }\n };\n}\n\n// Watchdog mode.\n// This runs when an inject node sends msg.topic = \"watchdog\".\nif (watchdogTick) {\n const timeoutSeconds = 120;\n const now = Date.now();\n const out = [null, null, null];\n const offlineNames = [];\n\n for (const [name, cfg] of Object.entries(inverterConfigs)) {\n const lastSeen = flow.get(`${name}_last_seen_ms`) || 0;\n const ageSeconds = lastSeen ? (now - lastSeen) / 1000 : Infinity;\n const alreadyOffline = flow.get(`${name}_online`) === false;\n\n if (ageSeconds > timeoutSeconds && !alreadyOffline) {\n const offlinePayload = {\n InverterStatus: -1,\n _watchdog: true,\n _ageSeconds: Math.round(ageSeconds)\n };\n\n flow.set(`${name}_online`, false);\n out[cfg.output] = buildOutputForInverter(name, offlinePayload, true);\n offlineNames.push(name);\n }\n }\n\n if (offlineNames.length === 0) {\n node.status({\n fill: \"green\",\n shape: \"dot\",\n text: \"watchdog ok\"\n });\n return [null, null, null];\n }\n\n node.status({\n fill: \"red\",\n shape: \"ring\",\n text: `watchdog offline: ${offlineNames.join(\", \")}`\n });\n\n return out;\n}\n\n// Normal MQTT mode.\nconst parts = String(msg.topic || \"\").split(\"/\");\n\nif (parts.length !== 2 || parts[0] !== \"Solar\") {\n return [null, null, null];\n}\n\nconst name = parts[1];\nconst cfg = inverterConfigs[name];\n\nif (!cfg) {\n node.status({\n fill: \"grey\",\n shape: \"ring\",\n text: `Ignored ${msg.topic}`\n });\n return [null, null, null];\n}\n\nconst offline =\n forcedOffline ||\n n(d.InverterStatus) === -1 ||\n !hasUsefulPayload(d);\n\nif (!offline) {\n flow.set(`${name}_last_seen_ms`, Date.now());\n}\n\nflow.set(`${name}_online`, !offline);\n\nconst out = [null, null, null];\nout[cfg.output] = buildOutputForInverter(name, d, offline);\n\nlet statusText;\n\nif (offline) {\n statusText = forcedOffline\n ? `${name} offline empty payload`\n : `${name} offline`;\n} else {\n statusText = `${name} ${cfg.model}: ${Math.round(n(d.OutputPower))} W, St=${sunspecStatus(d, false, n(d.OutputPower))}`;\n}\n\nnode.status({\n fill: offline ? \"red\" : n(d.OutputPower) > 0 ? \"green\" : \"yellow\",\n shape: offline ? \"ring\" : \"dot\",\n text: statusText\n});\n\nreturn out;",
"outputs": 3,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 620,
"y": 120,
"wires": [
[
"write_live_inverter1"
],
[
"write_live_inverter2"
],
[
"write_live_inverter3"
]
]
},
{
"id": "write_live_inverter1",
"type": "modbus-flex-write",
"z": "flow_sunspec_mqtt_growatt",
"name": "Write live Inverter 1 3P",
"showStatusActivities": true,
"showErrors": true,
"showWarnings": true,
"server": "modbus_client_5021",
"emptyMsgOnFail": false,
"keepMsgProperties": false,
"delayOnStart": false,
"startDelayTime": "",
"x": 1000,
"y": 80,
"wires": [
[],
[]
]
},
{
"id": "write_live_inverter2",
"type": "modbus-flex-write",
"z": "flow_sunspec_mqtt_growatt",
"name": "Write live Inverter 2 1P",
"showStatusActivities": true,
"showErrors": true,
"showWarnings": true,
"server": "modbus_client_5022",
"emptyMsgOnFail": false,
"keepMsgProperties": false,
"delayOnStart": false,
"startDelayTime": "",
"x": 1000,
"y": 140,
"wires": [
[],
[]
]
},
{
"id": "write_live_inverter3",
"type": "modbus-flex-write",
"z": "flow_sunspec_mqtt_growatt",
"name": "Write live Inverter 3 1P",
"showStatusActivities": true,
"showErrors": true,
"showWarnings": true,
"server": "modbus_client_5023",
"emptyMsgOnFail": false,
"keepMsgProperties": false,
"delayOnStart": false,
"startDelayTime": "",
"x": 1000,
"y": 200,
"wires": [
[],
[]
]
},
{
"id": "inject_static",
"type": "inject",
"z": "flow_sunspec_mqtt_growatt",
"name": "Initialize static SunSpec blocks",
"props": [
{
"p": "payload"
}
],
"repeat": "",
"crontab": "",
"once": true,
"onceDelay": "0.5",
"topic": "",
"payload": "",
"payloadType": "date",
"x": 190,
"y": 420,
"wires": [
[
"fn_static_blocks"
]
]
},
{
"id": "fn_static_blocks",
"type": "function",
"z": "flow_sunspec_mqtt_growatt",
"name": "Build static blocks for all three inverters",
"func": "const inverters = [\n {\n name: \"inverter1\",\n manufacturer: \"Growatt\",\n model: \"THREE_PHASE_INVERTER_MODEL\",\n version: \"1.0\",\n serial: \"SERIAL_INVERTER_1\",\n maxWatt: 4000,\n phase: 3,\n sunspecModel: 103,\n output: 0\n },\n {\n name: \"inverter2\",\n manufacturer: \"Growatt\",\n model: \"SINGLE_PHASE_INVERTER_MODEL_A\",\n version: \"1.0\",\n serial: \"SERIAL_INVERTER_2\",\n maxWatt: 2500,\n phase: 1,\n sunspecModel: 101,\n output: 1\n },\n {\n name: \"inverter3\",\n manufacturer: \"Growatt\",\n model: \"SINGLE_PHASE_INVERTER_MODEL_B\",\n version: \"1.0\",\n serial: \"SERIAL_INVERTER_3\",\n maxWatt: 1500,\n phase: 1,\n sunspecModel: 101,\n output: 2\n }\n];\n\nfunction sf(value) {\n return value < 0 ? 0x10000 + value : value;\n}\n\nfunction writeStringToRegisters(regs, startRegister, maxChars, text) {\n const value = String(text || \"\");\n\n for (let i = 0; i < maxChars / 2; i++) {\n regs[startRegister + i] = 0;\n }\n\n for (let i = 0; i < value.length && i < maxChars; i++) {\n const regIndex = startRegister + Math.floor(i / 2);\n\n if (i % 2 === 0) {\n regs[regIndex] = value.charCodeAt(i) << 8;\n } else {\n regs[regIndex] |= value.charCodeAt(i);\n }\n }\n}\n\nfunction buildStaticRegisters(inv) {\n const regs = new Array(200).fill(0xFFFF);\n\n // SunSpec identifier\n regs[0] = 0x5375; // \"Su\"\n regs[1] = 0x6E53; // \"nS\"\n\n // Common Model, SunSpec Model 1\n regs[2] = 1;\n regs[3] = 66;\n\n // Clear Common Model string area\n for (let i = 4; i < 68; i++) {\n regs[i] = 0;\n }\n\n // Common Model fields\n writeStringToRegisters(regs, 4, 32, inv.manufacturer); // Mn\n writeStringToRegisters(regs, 20, 32, inv.model); // Md\n writeStringToRegisters(regs, 36, 16, \"\"); // Opt\n writeStringToRegisters(regs, 44, 16, inv.version); // Vr\n writeStringToRegisters(regs, 52, 32, inv.serial); // SN\n\n // Inverter Model starts at register 68\n regs[68] = 1;\n regs[69] = 0xFFFF;\n regs[70] = inv.sunspecModel; // 101 single-phase, 103 three-phase\n regs[71] = 50;\n\n // Clear live inverter fields\n for (let i = 72; i <= 113; i++) {\n regs[i] = 0;\n }\n\n // AC current fields\n regs[72] = 0; // Total AC amps\n regs[73] = 0; // Phase A amps\n\n if (inv.phase === 3) {\n regs[74] = 0; // Phase B amps\n regs[75] = 0; // Phase C amps\n }\n\n regs[76] = sf(-2); // AC current scale factor\n\n // AC voltage fields\n if (inv.phase === 3) {\n regs[77] = 0; // AB voltage\n regs[78] = 0; // BC voltage\n regs[79] = 0; // CA voltage\n regs[80] = 0; // AN voltage\n regs[81] = 0; // BN voltage\n regs[82] = 0; // CN voltage\n } else {\n regs[80] = 0; // AC voltage for single-phase\n }\n\n regs[83] = 0; // AC voltage scale factor\n\n // AC power\n regs[84] = 0; // AC watts\n regs[85] = 0; // AC watts scale factor\n\n // Frequency\n regs[86] = 0; // AC frequency\n regs[87] = sf(-2); // Frequency scale factor\n\n // Lifetime energy\n regs[94] = 0; // Wh high word\n regs[95] = 0; // Wh low word\n regs[96] = 0; // Wh scale factor\n\n // DC values\n regs[97] = 0; // DC amps\n regs[98] = sf(-2); // DC amps scale factor\n regs[99] = 0; // DC volts\n regs[100] = 0; // DC volts scale factor\n regs[101] = 0; // DC watts\n regs[102] = 0; // DC watts scale factor\n\n // Temperature\n regs[103] = 0; // Temperature\n regs[107] = sf(-1); // Temperature scale factor\n\n // Inverter operating state, St\n // 1 = Off\n regs[108] = 1;\n\n // Events\n regs[110] = 0;\n regs[111] = 0;\n regs[112] = 0;\n regs[113] = 0;\n\n // Nameplate Model 120\n regs[122] = 120;\n regs[123] = 26;\n regs[124] = 4; // DER type 4 = PV\n regs[125] = inv.maxWatt; // WRtg\n regs[126] = 0; // WRtg scale factor\n\n // Controls Model 123\n regs[150] = 123;\n regs[151] = 24;\n regs[155] = 100; // WMaxLimPct default 100 percent\n regs[157] = 0; // WMaxLimPct_RvrtTms, no auto revert\n regs[159] = 1; // WMaxLim_Ena, enabled\n regs[173] = 0; // WMaxLimPct_SF\n\n // End Model\n regs[176] = 0xFFFF;\n regs[177] = 0;\n\n // Convert 16-bit registers to 8-bit byte array for modbus-server node\n const regs8bit = [];\n\n for (const num of regs) {\n const value = Number(num) & 0xFFFF;\n regs8bit.push((value >> 8) & 0xFF, value & 0xFF);\n }\n\n return regs8bit;\n}\n\nconst out = [null, null, null];\n\nfor (const inv of inverters) {\n out[inv.output] = {\n payload: {\n value: buildStaticRegisters(inv),\n register: \"holding\",\n address: 0,\n disableMsgOutput: 0\n }\n };\n}\n\nnode.status({\n fill: \"blue\",\n shape: \"dot\",\n text: \"Static SunSpec blocks initialized, St=Off\"\n});\n\nreturn out;",
"outputs": 3,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 550,
"y": 420,
"wires": [
[
"server_inverter1"
],
[
"server_inverter2"
],
[
"server_inverter3"
]
]
},
{
"id": "server_inverter1",
"type": "modbus-server",
"z": "flow_sunspec_mqtt_growatt",
"name": "Inverter 1 3P SunSpec Server",
"logEnabled": true,
"hostname": "127.0.0.1",
"serverPort": "5021",
"responseDelay": 100,
"delayUnit": "ms",
"coilsBufferSize": "1000",
"holdingBufferSize": "1000",
"inputBufferSize": "1000",
"discreteBufferSize": "1000",
"showErrors": true,
"showStatusActivities": true,
"x": 960,
"y": 380,
"wires": [
[],
[],
[],
[],
[]
]
},
{
"id": "server_inverter2",
"type": "modbus-server",
"z": "flow_sunspec_mqtt_growatt",
"name": "Inverter 2 1P SunSpec Server",
"logEnabled": true,
"hostname": "127.0.0.1",
"serverPort": "5022",
"responseDelay": 100,
"delayUnit": "ms",
"coilsBufferSize": "1000",
"holdingBufferSize": "1000",
"inputBufferSize": "1000",
"discreteBufferSize": "1000",
"showErrors": true,
"showStatusActivities": true,
"x": 960,
"y": 480,
"wires": [
[],
[],
[],
[],
[]
]
},
{
"id": "server_inverter3",
"type": "modbus-server",
"z": "flow_sunspec_mqtt_growatt",
"name": "Inverter 3 1P SunSpec Server",
"logEnabled": true,
"hostname": "127.0.0.1",
"serverPort": "5023",
"responseDelay": 100,
"delayUnit": "ms",
"coilsBufferSize": "1000",
"holdingBufferSize": "1000",
"inputBufferSize": "1000",
"discreteBufferSize": "1000",
"showErrors": true,
"showStatusActivities": true,
"x": 960,
"y": 580,
"wires": [
[],
[],
[],
[],
[]
]
},
{
"id": "read_rate_1",
"type": "modbus-read",
"z": "flow_sunspec_mqtt_growatt",
"name": "Read Inverter 1 active rate",
"topic": "",
"showStatusActivities": true,
"logIOActivities": false,
"showErrors": true,
"showWarnings": true,
"unitid": "1",
"dataType": "HoldingRegister",
"adr": "155",
"quantity": "1",
"rate": "5",
"rateUnit": "s",
"delayOnStart": false,
"enableDeformedMessages": false,
"startDelayTime": "",
"server": "modbus_client_5021",
"useIOFile": false,
"ioFile": "",
"useIOForPayload": false,
"emptyMsgOnFail": false,
"x": 190,
"y": 680,
"wires": [
[
"cmd_rate_1"
],
[]
]
},
{
"id": "cmd_rate_1",
"type": "function",
"z": "flow_sunspec_mqtt_growatt",
"name": "Command Inverter 1 activerate",
"func": "const value = Array.isArray(msg.payload) ? Number(msg.payload[0]) : Number(msg.payload);\nconst clamped = Math.max(0, Math.min(100, Number.isFinite(value) ? Math.round(value) : 100));\nconst inverter = \"inverter1\";\nif (flow.get(`${inverter}_online`) === false) {\n node.status({ fill: \"grey\", shape: \"ring\", text: `${inverter} offline, command suppressed` });\n return null;\n}\nnode.status({ fill: clamped === 100 ? \"green\" : clamped === 0 ? \"red\" : \"yellow\", shape: \"dot\", text: `${inverter} active rate ${clamped}%` });\nreturn { topic: `Solar/${inverter}/command/power/set/activerate`, payload: JSON.stringify({ value: clamped }) };",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 470,
"y": 680,
"wires": [
[
"mqtt_out_commands"
]
]
},
{
"id": "read_rate_2",
"type": "modbus-read",
"z": "flow_sunspec_mqtt_growatt",
"name": "Read Inverter 2 active rate",
"topic": "",
"showStatusActivities": true,
"logIOActivities": false,
"showErrors": true,
"showWarnings": true,
"unitid": "1",
"dataType": "HoldingRegister",
"adr": "155",
"quantity": "1",
"rate": "5",
"rateUnit": "s",
"delayOnStart": false,
"enableDeformedMessages": false,
"startDelayTime": "",
"server": "modbus_client_5022",
"useIOFile": false,
"ioFile": "",
"useIOForPayload": false,
"emptyMsgOnFail": false,
"x": 190,
"y": 740,
"wires": [
[
"cmd_rate_2"
],
[]
]
},
{
"id": "cmd_rate_2",
"type": "function",
"z": "flow_sunspec_mqtt_growatt",
"name": "Command Inverter 2 activerate",
"func": "const value = Array.isArray(msg.payload) ? Number(msg.payload[0]) : Number(msg.payload);\nconst clamped = Math.max(0, Math.min(100, Number.isFinite(value) ? Math.round(value) : 100));\nconst inverter = \"inverter2\";\nif (flow.get(`${inverter}_online`) === false) {\n node.status({ fill: \"grey\", shape: \"ring\", text: `${inverter} offline, command suppressed` });\n return null;\n}\nnode.status({ fill: clamped === 100 ? \"green\" : clamped === 0 ? \"red\" : \"yellow\", shape: \"dot\", text: `${inverter} active rate ${clamped}%` });\nreturn { topic: `Solar/${inverter}/command/power/set/activerate`, payload: JSON.stringify({ value: clamped }) };",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 470,
"y": 740,
"wires": [
[
"mqtt_out_commands"
]
]
},
{
"id": "read_rate_3",
"type": "modbus-read",
"z": "flow_sunspec_mqtt_growatt",
"name": "Read Inverter 3 active rate",
"topic": "",
"showStatusActivities": true,
"logIOActivities": false,
"showErrors": true,
"showWarnings": true,
"unitid": "1",
"dataType": "HoldingRegister",
"adr": "155",
"quantity": "1",
"rate": "5",
"rateUnit": "s",
"delayOnStart": false,
"enableDeformedMessages": false,
"startDelayTime": "",
"server": "modbus_client_5023",
"useIOFile": false,
"ioFile": "",
"useIOForPayload": false,
"emptyMsgOnFail": false,
"x": 190,
"y": 800,
"wires": [
[
"cmd_rate_3"
],
[]
]
},
{
"id": "cmd_rate_3",
"type": "function",
"z": "flow_sunspec_mqtt_growatt",
"name": "Command Inverter 3 activerate",
"func": "const value = Array.isArray(msg.payload) ? Number(msg.payload[0]) : Number(msg.payload);\nconst clamped = Math.max(0, Math.min(100, Number.isFinite(value) ? Math.round(value) : 100));\nconst inverter = \"inverter3\";\nif (flow.get(`${inverter}_online`) === false) {\n node.status({ fill: \"grey\", shape: \"ring\", text: `${inverter} offline, command suppressed` });\n return null;\n}\nnode.status({ fill: clamped === 100 ? \"green\" : clamped === 0 ? \"red\" : \"yellow\", shape: \"dot\", text: `${inverter} active rate ${clamped}%` });\nreturn { topic: `Solar/${inverter}/command/power/set/activerate`, payload: JSON.stringify({ value: clamped }) };",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 470,
"y": 800,
"wires": [
[
"mqtt_out_commands"
]
]
},
{
"id": "mqtt_out_commands",
"type": "mqtt out",
"z": "flow_sunspec_mqtt_growatt",
"name": "MQTT active-rate commands",
"topic": "",
"qos": "0",
"retain": "false",
"respTopic": "",
"contentType": "",
"userProps": "",
"correl": "",
"expiry": "",
"broker": "mqtt_broker_config",
"x": 780,
"y": 780,
"wires": []
},
{
"id": "inject_watchdog",
"type": "inject",
"z": "flow_sunspec_mqtt_growatt",
"name": "Initialize MQTT Watchdog",
"props": [
{
"p": "payload"
},
{
"p": "topic",
"vt": "str"
}
],
"repeat": "30",
"crontab": "",
"once": true,
"onceDelay": "0.5",
"topic": "watchdog",
"payload": "",
"payloadType": "date",
"x": 200,
"y": 200,
"wires": [
[
"fn_live_registers"
]
]
},
{
"id": "mqtt_broker_config",
"type": "mqtt-broker",
"name": "MQTT_BROKER_NAME",
"broker": "MQTT_BROKER_IP_OR_HOST",
"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": "modbus_client_5021",
"type": "modbus-client",
"name": "Inverter 1 Modbus Client 5021",
"clienttype": "tcp",
"bufferCommands": true,
"stateLogEnabled": false,
"queueLogEnabled": false,
"failureLogEnabled": true,
"tcpHost": "127.0.0.1",
"tcpPort": "5021",
"tcpType": "DEFAULT",
"serialPort": "/dev/ttyUSB",
"serialType": "RTU-BUFFERD",
"serialBaudrate": 9600,
"serialDatabits": 8,
"serialStopbits": 1,
"serialParity": "none",
"serialConnectionDelay": 100,
"serialAsciiResponseStartDelimiter": "0x3A",
"unit_id": 1,
"commandDelay": 1,
"clientTimeout": 1000,
"reconnectOnTimeout": true,
"reconnectTimeout": 2000,
"parallelUnitIdsAllowed": true,
"showErrors": false,
"showWarnings": true,
"showLogs": true
},
{
"id": "modbus_client_5022",
"type": "modbus-client",
"name": "Inverter 2 Modbus Client 5022",
"clienttype": "tcp",
"bufferCommands": true,
"stateLogEnabled": false,
"queueLogEnabled": false,
"failureLogEnabled": true,
"tcpHost": "127.0.0.1",
"tcpPort": "5022",
"tcpType": "DEFAULT",
"serialPort": "/dev/ttyUSB",
"serialType": "RTU-BUFFERD",
"serialBaudrate": 9600,
"serialDatabits": 8,
"serialStopbits": 1,
"serialParity": "none",
"serialConnectionDelay": 100,
"serialAsciiResponseStartDelimiter": "0x3A",
"unit_id": 1,
"commandDelay": 1,
"clientTimeout": 1000,
"reconnectOnTimeout": true,
"reconnectTimeout": 2000,
"parallelUnitIdsAllowed": true,
"showErrors": false,
"showWarnings": true,
"showLogs": true
},
{
"id": "modbus_client_5023",
"type": "modbus-client",
"name": "Inverter 3 Modbus Client 5023",
"clienttype": "tcp",
"bufferCommands": true,
"stateLogEnabled": false,
"queueLogEnabled": false,
"failureLogEnabled": true,
"tcpHost": "127.0.0.1",
"tcpPort": "5023",
"tcpType": "DEFAULT",
"serialPort": "/dev/ttyUSB",
"serialType": "RTU-BUFFERD",
"serialBaudrate": 9600,
"serialDatabits": 8,
"serialStopbits": 1,
"serialParity": "none",
"serialConnectionDelay": 100,
"serialAsciiResponseStartDelimiter": "0x3A",
"unit_id": 1,
"commandDelay": 1,
"clientTimeout": 1000,
"reconnectOnTimeout": true,
"reconnectTimeout": 2000,
"parallelUnitIdsAllowed": true,
"showErrors": false,
"showWarnings": true,
"showLogs": true
},
{
"id": "global_config_modbus",
"type": "global-config",
"env": [],
"modules": {
"node-red-contrib-modbus": "5.45.2"
}
}
]