diff --git a/CODEOWNERS b/CODEOWNERS
index dde5c21974c..8c912b54d30 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -130,6 +130,7 @@
/bundles/org.openhab.binding.gree/ @markus7017
/bundles/org.openhab.binding.groheondus/ @FlorianSW
/bundles/org.openhab.binding.groupepsa/ @arjanmels
+/bundles/org.openhab.binding.growatt/ @andrewfg
/bundles/org.openhab.binding.guntamatic/ @MikeTheTux
/bundles/org.openhab.binding.haassohnpelletstove/ @chingon007
/bundles/org.openhab.binding.harmonyhub/ @digitaldan
diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml
index 761bb476a1d..2c85b5dcb19 100644
--- a/bom/openhab-addons/pom.xml
+++ b/bom/openhab-addons/pom.xml
@@ -641,6 +641,11 @@
org.openhab.binding.groupepsa${project.version}
+
+ org.openhab.addons.bundles
+ org.openhab.binding.growatt
+ ${project.version}
+ org.openhab.addons.bundlesorg.openhab.binding.guntamatic
diff --git a/bundles/org.openhab.binding.growatt/NOTICE b/bundles/org.openhab.binding.growatt/NOTICE
new file mode 100644
index 00000000000..38d625e3492
--- /dev/null
+++ b/bundles/org.openhab.binding.growatt/NOTICE
@@ -0,0 +1,13 @@
+This content is produced and maintained by the openHAB project.
+
+* Project home: https://www.openhab.org
+
+== Declared Project Licenses
+
+This program and the accompanying materials are made available under the terms
+of the Eclipse Public License 2.0 which is available at
+https://www.eclipse.org/legal/epl-2.0/.
+
+== Source Code
+
+https://github.com/openhab/openhab-addons
diff --git a/bundles/org.openhab.binding.growatt/README.md b/bundles/org.openhab.binding.growatt/README.md
new file mode 100644
index 00000000000..e960d787437
--- /dev/null
+++ b/bundles/org.openhab.binding.growatt/README.md
@@ -0,0 +1,349 @@
+# Growatt Binding
+
+This binding supports the integration of Growatt solar inverters.
+
+It depends on the independent [Grott](https://github.com/johanmeijer/grott#the-growatt-inverter-monitor) proxy server application.
+This intercepts the logging data that the Growatt inverter data logger normally sends directly to the Growatt cloud server.
+It sends the original (encoded) data onwards to the cloud server (so the cloud server will not notice anything different).
+But it also sends a (decoded) copy to openHAB as well.
+
+## Supported Things
+
+The binding supports two types of things:
+
+- `bridge`: The bridge is the interface to the Grott application; it receives the data from all inverters.
+- `inverter`: The inverter thing contains channels which are updated with solor production and consumption data.
+
+## Discovery
+
+There is no automatic discovery of the bridge.
+However if a bridge exists and it receives inverter data, then a matching inverter thing is created in the Inbox.
+
+## Thing Configuration
+
+The `bridge` thing allows configuration of the user credentials, which are only required if you want to send inverter commands via the Growatt cloud server:
+
+| Name | Type | Description | Advanced |Required |
+|-----------|---------|------------------------------------------------------------------------------------------|----------|---------|
+| userName | text | User name for the Growatt Shine app. Only needed if using [Rule Actions](#rule-actions) | yes | no |
+| password | text | Password for the Growatt Shine app. Only needed if using [Rule Actions](#rule-actions) | yes | no |
+
+The `inverter` thing requires configuration of its serial number resp. `deviceId`:
+
+| Name | Type | Description | Required |
+|-----------|---------|------------------------------------------------------------------------------------------|----------|
+| deviceId | text | Device serial number or id as configured in the Growatt cloud and the Grott application. | yes |
+
+## Channels
+
+The `bridge` thing has no channels.
+
+The `inverter` thing supports many possible channels relating to solar generation and consumption.
+All channels are read-only.
+Depending on the inverter model, and its configuration, not all of the channels will be present.
+The list of all possible channels is as follows:
+
+| Channel | Type | Description | Advanced |
+|-------------------------------|---------------------------|------------------------------------------------------|----------|
+| system-status | Number:Dimensionless | Inverter status code. | |
+| pv1-voltage | Number:ElectricPotential | DC voltage from solar panel string #1. | yes |
+| pv2-voltage | Number:ElectricPotential | DC voltage from solar panel string #2. | yes |
+| pv1-current | Number:ElectricCurrent | DC current from solar panel string #1. | yes |
+| pv2-current | Number:ElectricCurrent | DC current from solar panel string #2. | yes |
+| pv-power | Number:Power | Total DC solar input power. | |
+| pv1-power | Number:Power | DC power from solar panel string #1. | yes |
+| pv2-power | Number:Power | DC power from solar panel string #2. | yes |
+| grid-frequency | Number:Frequency | Frequency of the grid. | yes |
+| grid-voltage-r | Number:ElectricPotential | Voltage of the grid (phase #R). | |
+| grid-voltage-s | Number:ElectricPotential | Voltage of the grid phase #S. | yes |
+| grid-voltage-t | Number:ElectricPotential | Voltage of the grid phase #T. | yes |
+| grid-voltage-rs | Number:ElectricPotential | Voltage of the grid phases #RS. | yes |
+| grid-voltage-st | Number:ElectricPotential | Voltage of the grid phases #ST. | yes |
+| grid-voltage-tr | Number:ElectricPotential | Voltage of the grid phases #TR. | yes |
+| inverter-current-r | Number:ElectricCurrent | AC current from inverter (phase #R). | yes |
+| inverter-current-s | Number:ElectricCurrent | AC current from inverter phase #S. | yes |
+| inverter-current-t | Number:ElectricCurrent | AC current from inverter phase #T. | yes |
+| inverter-power | Number:Power | Total AC output power from inverter. | |
+| inverter-power-r | Number:Power | AC power from inverter (phase #R). | |
+| inverter-power-s | Number:Power | AC power from inverter phase #S. | yes |
+| inverter-power-t | Number:Power | AC power from inverter phase #T. | yes |
+| inverter-va | Number:Power | AC VA from inverter. | yes |
+| export-power | Number:Power | Power exported to grid. | |
+| export-power-r | Number:Power | Power exported to grid phase #R. | yes |
+| export-power-s | Number:Power | Power exported to grid phase #S. | yes |
+| export-power-t | Number:Power | Power exported to grid phase #T. | yes |
+| import-power | Number:Power | Power imported from grid. | |
+| import-power-r | Number:Power | Power imported from grid phase #R. | yes |
+| import-power-s | Number:Power | Power imported from grid phase #S. | yes |
+| import-power-t | Number:Power | Power imported from grid phase #T. | yes |
+| load-power | Number:Power | Power supplied to load. | |
+| load-power-r | Number:Power | Power supplied to load phase #R. | yes |
+| load-power-s | Number:Power | Power supplied to load phase #S. | yes |
+| load-power-t | Number:Power | Power supplied to load phase #T. | yes |
+| charge-power | Number:Power | Battery charge power. | |
+| charge-current | Number:ElectricCurrent | Battery charge current. | yes |
+| discharge-power | Number:Power | Battery discharge power. | |
+| discharge-va | Number:Power | Battery discharge VA. | yes |
+| pv-energy-today | Number:Energy | DC energy collected by solar panels today. | |
+| pv1-energy-today | Number:Energy | DC energy collected by solar panels string #1 today. | yes |
+| pv2-energy-today | Number:Energy | DC energy collected by solar panels string #2 today. | yes |
+| pv-energy-total | Number:Energy | Total DC energy collected by solar panels. | |
+| pv1-energy-total | Number:Energy | Total DC energy collected by solar panels string #1. | yes |
+| pv2-energy-total | Number:Energy | Total DC energy collected by solar panels string #2. | yes |
+| inverter-energy-today | Number:Energy | AC energy produced by inverter today. | |
+| inverter-energy-total | Number:Energy | Total AC energy produced by inverter. | |
+| export-energy-today | Number:Energy | Energy exported today. | |
+| export-energy-total | Number:Energy | Total energy exported. | |
+| import-energy-today | Number:Energy | Energy imported today. | |
+| import-energy-total | Number:Energy | Total energy imported. | |
+| load-energy-today | Number:Energy | Energy supplied to load today. | |
+| load-energy-total | Number:Energy | Total energy supplied to load. | |
+| import-charge-energy-today | Number:Energy | Energy imported to charge battery today. | |
+| import-charge-energy-total | Number:Energy | Total energy imported to charge battery. | |
+| inverter-charge-energy-today | Number:Energy | Inverter energy to charge battery today. | |
+| inverter-charge-energy-total | Number:Energy | Total inverter energy to charge battery. | |
+| discharge-energy-today | Number:Energy | Energy consumed from battery. | |
+| discharge-energy-total | Number:Energy | Total energy consumed from battery. | |
+| total-work-time | Number:Time | Total work time of the system. | yes |
+| p-bus-voltage | Number:ElectricPotential | P Bus voltage. | yes |
+| n-bus-voltage | Number:ElectricPotential | N Bus voltage. | yes |
+| sp-bus-voltage | Number:ElectricPotential | SP Bus voltage. | yes |
+| pv-temperature | Number:Temperature | Temperature of the solar panels (string #1). | yes |
+| pv-ipm-temperature | Number:Temperature | Temperature of the IPM. | yes |
+| pv-boost-temperature | Number:Temperature | Boost temperature. | yes |
+| temperature-4 | Number:Temperature | Temperature #4. | yes |
+| pv2-temperature | Number:Temperature | Temperature of the solar panels (string #2). | yes |
+| battery-type | Number:Dimensionless | Type code of the battery. | yes |
+| battery-temperature | Number:Temperature | Battery temperature. | yes |
+| battery-voltage | Number:ElectricPotential | Battery voltage. | yes |
+| battery-display | Number:Dimensionless | Battery display code. | yes |
+| battery-soc | Number:Dimensionless | Battery State of Charge percent. | yes |
+| system-fault-0 | Number:Dimensionless | System fault code #0. | yes |
+| system-fault-1 | Number:Dimensionless | System fault code #1. | yes |
+| system-fault-2 | Number:Dimensionless | System fault code #2. | yes |
+| system-fault-3 | Number:Dimensionless | System fault code #3. | yes |
+| system-fault-4 | Number:Dimensionless | System fault code #4. | yes |
+| system-fault-5 | Number:Dimensionless | System fault code #5. | yes |
+| system-fault-6 | Number:Dimensionless | System fault code #6. | yes |
+| system-fault-7 | Number:Dimensionless | System fault code #7. | yes |
+| system-work-mode | Number:Dimensionless | System work mode code. | yes |
+| sp-display-status | Number:Dimensionless | Solar panel display status code. | yes |
+| constant-power-ok | Number:Dimensionless | Constant power OK code. | yes |
+| load-percent | Number:Dimensionless | Percent of full load. | yes |
+| rac | Number:Power | Reactive 'power' (var). | yes |
+| erac-today | Number:Energy | Reactive 'energy' today (kvarh). | yes |
+| erac-total | Number:Energy | Total reactive 'energy' (kvarh). | yes |
+
+## Rule Actions
+
+This binding includes rule actions, which allow you to setup programs for battery charging and discharging.
+Each inverter thing has a separate actions instance, which can be retrieved as follows.
+
+```php
+val growattActions = getActions("growatt", "growatt:inverter:home:sph")
+```
+
+Where the first parameter must always be `growatt` and the second must be the full inverter thing UID.
+Once the action instance has been retrieved, you can invoke the following method:
+
+```php
+growattActions.setupBatteryProgram(int programMode, @Nullable Integer powerLevel, @Nullable Integer stopSOC, @Nullable Boolean enableAcCharging, @Nullable String startTime, @Nullable String stopTime, @Nullable Boolean enableProgram)
+```
+
+The meaning of the method parameters is as follows:
+
+| Parameter | Description |
+|-------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------|
+| programMode | The program mode to set i.e. 'Load First' (0), 'Battery First' (1), 'Grid First' (2). |
+| powerLevel2) | The percentage rate of battery (dis-)charge e.g. 100 - in 'Battery First' mode => charge power, otherwise => discharge power. |
+| stopSOC2) | The battery SOC (state of charge) percentage when the program shall stop e.g. 20 - in 'Battery First' mode => max. SOC, otherwise => min. SOC. |
+| enableAcCharging2) | Allow the battery to be charged from the AC mains supply e.g. true, false. |
+| startTime1,2) | String representation of the local time when the program `time segment` shall start e.g. "00:15" |
+| stopTime1,2) | String representation of the local time when the program `time segment` shall stop e.g. "06:45" |
+| enableProgram1,2) | Enable / disable the program `time segment` e.g. true, false |
+
+Notes:
+
+-1) ***WARNING*** inverters have different program `time segment`'s for each `programMode`.
+To prevent unexpected results do not overlap the `time segment`'s.
+
+-2) Depending on inverter type and `programMode` certain parameters may accept 'null' values.
+The 'mix', 'sph' and 'spa' types set the battery program in a single command, so all parameters - except `enableAcCharging` - **must** be ***non-***'null'.
+By contrast 'tlx' types set the battery program in up to four partial commands, and you may pass 'null' parameters in order to omit a partial command.
+The permission for passing 'null' parameters, and the effect of such 'null' parameters, is shown in detail in the table below:
+
+| Parameter | Permission for.. / effect of.. passing a 'null' parameter |
+|------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------|
+| programMode | Shall **not** be 'null' under any circumstance! |
+| powerLevel | May be 'null' on 'tlx' inverters whereby the prior `programMode` / `powerLevel` continues to apply. |
+| stopSOC | May be 'null' on 'tlx' inverters whereby the prior `programMode` / `stopSOC` continues to apply. |
+| enableAcCharging | If 'null' the prior `enableAcCharging` (if any) continues to apply. Shall **not** be 'null' on 'mix' inverter 'Battery First' program. |
+| startTime, stopTime, enableProgram | May be 'null' on 'tlx' inverters whereby the prior `programMode` / `time segment` continues to apply - note all 'null' resp. non-'null'. |
+
+Example:
+
+```php
+rule "Setup Solar Battery Charging Program"
+when
+ Time cron "0 10 0 ? * * *"
+then
+ val growattActions = getActions("growatt", "growatt:inverter:home:ABCD1234") // thing UID
+ if (growattActions === null) {
+ logWarn("Rules", "growattActions is null")
+ } else {
+
+ // fixed algorithm parameters
+ val Integer programMode = 1 // 0 = Load First, 1 = Battery First, 2 = Grid First
+ val Integer powerLevel = 23 // percent
+ val Boolean enableAcCharging = true
+ val String startTime = "00:20"
+ val String stopTime = "07:00"
+ val Boolean enableProgram = true
+
+ // calculation intermediaries
+ val batteryFull = 6500.0 // Wh
+ val batteryMin = 500.0 // Wh
+ val daylightConsumption = 10000.0 // Wh
+ val maximumSOC = 100.0 // percent
+ val minimumSOC = 20.0 // percent
+
+
+ // calculate stop SOC based on weather forecast
+ val Double solarForecast = (ForecastSolar_PV_Whole_Site_Forecast_Today.state as QuantityType).toUnit("Wh").doubleValue()
+ var Double targetSOC = (100.0 * (batteryMin + daylightConsumption - solarForecast)) / batteryFull
+ if (targetSOC > maximumSOC) {
+ targetSOC = maximumSOC
+ } else if (targetSOC < minimumSOC) {
+ targetSOC = minimumSOC
+ }
+
+ // convert to integer
+ val Integer stopSOC = targetSOC.intValue() // percent
+
+ logInfo("Rules", "Setup Charging Program:{solarForecast:" + solarForecast + "Wh, programMode:" + programMode + ", powerLevel:" + powerLevel + "%, stopSOC:" + stopSOC + "%, enableCharging:" + enableAcCharging + ", startTime:" + startTime + ", stopTime:" + stopTime + ", enableProgram:" + enableProgram +"}")
+ growattActions.setupBatteryProgram(programMode, powerLevel, stopSOC, enableAcCharging, startTime, stopTime, enableProgram)
+ }
+end
+```
+
+## Full Example
+
+### Example `.things` file
+
+```java
+Bridge growatt:bridge:home "Growattt Bridge" [userName="USERNAME", password="PASSWORD"] {
+ Thing inverter sph "Growatt SPH Inverter" [deviceId="INVERTERTID"]
+}
+```
+
+### Example `.items` file
+
+```java
+Number:ElectricPotential Solar_String1_Voltage "Solar String #1 PV Voltage" {channel="growatt:inverter:home:sph:pv1-voltage"}
+Number:ElectricCurrent Solar_String1_Current "Solar String #1 PV Current" {channel="growatt:inverter:home:sph:pv1-current"}
+Number:Power Solar_String1_Power "Solar String #1 PV Power" {channel="growatt:inverter:home:sph:pv1-power"}
+Number:Energy Solar_Output_Energy "Solar Output Energy Total" {channel="growatt:inverter:home:sph:pv-energy-total"}
+```
+
+Example using a transform profile to invert an item value:
+
+```java
+// charge item with positive value
+Number:Power Charge_Power "Charge Power [%.0f W]" {channel="growatt:inverter:home:sph:charge-power"}
+
+// discarge item with negative value
+Number:Power Discharge_Power "Discharge Power [%.0f W]" {channel="growatt:inverter:home:sph:discharge-power" [ profile="transform:JS", toItemScript="| Quantity(input).multiply(-1).toString();" ] }
+```
+
+## Grott Application Installation and Setup
+
+You can install the Grott application either on the same computer as openHAB or on another.
+The following assumes you will be running it on the same computer.
+The Grott application acts as a proxy server between your Growatt inverter and the Growatt cloud server.
+It intercepts data packets between the inverter and the cloud server, and it sends a copy of the intercepted data also to openHAB.
+
+**NOTE**: make sure that the Grott application is **FULLY OPERATIONAL** for your inverter **BEFORE** you create any things in openHAB!
+Otherwise the binding might create a wrong (or even empty) list of channels for the inverter thing.
+(Yet if you do make that mistake you can rectify it by deleting and recreating the thing).
+
+You should configure the Grott application via its `grott.ini` file.
+Configure Grott to match your inverter according to the [instructions](https://github.com/johanmeijer/grott#the-growatt-inverter-monitor).
+
+### Install Python
+
+If Python is not already installed on you computer, then install it first.
+And install the following additional necessary python packages:
+
+```bash
+sudo pip3 install paho-mqtt
+sudo pip3 install requests
+```
+
+### Install Grott
+
+First install the Grott application and the Grott application extension files in a Grott specific home folder.
+Note that Grott requires the `grottext.py` application extension in addition to the standard application files.
+The installation is as follows:
+
+- Create a 'home' sub-folder for Grott e.g. `/home//grott/`.
+- Copy `grott.py`, `grottconf.py`, `grottdata.py`, `grottproxy.py`, `grottsniffer.py`, `grottserver.py` to the home folder.
+- Copy `grottext.py` application extension to the home folder.
+- Copy `grott.ini` configuration file to the home folder.
+- Modify `grott.ini` to run in proxy mode; not in compatibility mode; show your inverter type; not run MQTT; not run PVOutput; enable the `grottext` extension; and set the openHAB `/growatt` servlet url.
+
+A suggested Grott configuration for openHAB is as follows:
+
+```php
+[Generic]
+mode = proxy
+compat = False
+invtype = sph // your inverter type
+
+[MQTT]
+nomqtt = True // disable mqtt
+
+[PVOutput]
+pvoutput = False // disable pvoutput
+
+[extension] // enable the 'grottext' extension
+extension = True
+extname = grottext
+extvar = {"url": "http://127.0.0.1:8080/growatt"} // or ip address of openHAB (if remote)
+```
+
+### Start Grott as a Service
+
+Finally you should set your computer to starts the Grott application automatically as a service when your computer starts.
+For Windows see wiki: https://github.com/johanmeijer/grott/wiki/Grott-as-a-service-(Windows)
+For Linux see wiki: https://github.com/johanmeijer/grott/wiki/Grott-as-a-service-(Linux)
+The service configuration for Linux is summarised below:
+
+- Copy the `grott.service` file to the `/etc/systemd/system/` folder
+- Modify `grott.service` to enter your user name; the Grott settings; the path to Python; and the path to the Grott application:
+
+```php
+[Service]
+SyslogIdentifier=grott
+User= // your username
+WorkingDirectory=/home//grott/ // your home grott folder
+ExecStart=-/usr/bin/python3 -u /home//grott/grott.py -v // ditto
+```
+
+And finally enable the Grott service:
+
+```bash
+sudo systemctl enable grott
+```
+
+### Route Growatt Inverter Logging via Grott Proxy
+
+Normally the Growatt inverter sends its logging data directly to port `5279` on the Growatt server at `server.growatt.com` (ip=47.91.67.66) on the cloud.
+Grott is a proxy server that interposes itself beween the inverter and the cloud server.
+i.e. it receives the inverter logging data and forwards it unchanged to the cloud server.
+
+**WARNING**: make sure that Grott is running on a computer with a **STATIC IP ADDRESS** (and note this safely)!
+Otherwise if the computer changes its ip address dynamically, it can no longer intercept the inverter data.
+This means **YOU WILL NO LONGER BE ABLE TO RESET THE INVERTER** to its original settings!
+
+You need to use the Growatt App to tell the inverter to send its logging data to the Grott proxy instead of to the cloud.
+See wiki: https://github.com/johanmeijer/grott/wiki/Rerouting-Growatt-Wifi-TCPIP-data-via-your-Grott-Server for more information.
diff --git a/bundles/org.openhab.binding.growatt/pom.xml b/bundles/org.openhab.binding.growatt/pom.xml
new file mode 100644
index 00000000000..649edba4841
--- /dev/null
+++ b/bundles/org.openhab.binding.growatt/pom.xml
@@ -0,0 +1,17 @@
+
+
+
+ 4.0.0
+
+
+ org.openhab.addons.bundles
+ org.openhab.addons.reactor.bundles
+ 4.2.0-SNAPSHOT
+
+
+ org.openhab.binding.growatt
+
+ openHAB Add-ons :: Bundles :: Growatt Binding
+
+
diff --git a/bundles/org.openhab.binding.growatt/src/main/feature/feature.xml b/bundles/org.openhab.binding.growatt/src/main/feature/feature.xml
new file mode 100644
index 00000000000..735964b4dbc
--- /dev/null
+++ b/bundles/org.openhab.binding.growatt/src/main/feature/feature.xml
@@ -0,0 +1,9 @@
+
+
+ mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features
+
+
+ openhab-runtime-base
+ mvn:org.openhab.addons.bundles/org.openhab.binding.growatt/${project.version}
+
+
diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/GrowattBindingConstants.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/GrowattBindingConstants.java
new file mode 100644
index 00000000000..9fc85488006
--- /dev/null
+++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/GrowattBindingConstants.java
@@ -0,0 +1,31 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.growatt.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.thing.ThingTypeUID;
+
+/**
+ * The {@link GrowattBindingConstants} class defines common constants, which are
+ * used across the whole binding.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ */
+@NonNullByDefault
+public class GrowattBindingConstants {
+
+ public static final String BINDING_ID = "growatt";
+
+ public static final ThingTypeUID THING_TYPE_BRIDGE = new ThingTypeUID(BINDING_ID, "bridge");
+ public static final ThingTypeUID THING_TYPE_INVERTER = new ThingTypeUID(BINDING_ID, "inverter");
+}
diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/GrowattChannels.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/GrowattChannels.java
new file mode 100644
index 00000000000..503fd1ccd46
--- /dev/null
+++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/GrowattChannels.java
@@ -0,0 +1,196 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.growatt.internal;
+
+import java.util.AbstractMap;
+import java.util.Map;
+
+import javax.measure.Unit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.library.unit.SIUnits;
+import org.openhab.core.library.unit.Units;
+
+/**
+ * The {@link GrowattChannels} class defines the channel ids and respective UoM and scaling factors.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ */
+@NonNullByDefault
+public class GrowattChannels {
+
+ /**
+ * Class encapsulating units of measure and scale information.
+ */
+ public static class UoM {
+ public final Unit> units;
+ public final float divisor;
+
+ public UoM(Unit> units, float divisor) {
+ this.units = units;
+ this.divisor = divisor;
+ }
+ }
+
+ /**
+ * Map of the channel ids to their respective UoM and scaling factors
+ */
+ private static final Map CHANNEL_ID_UOM_MAP = Map.ofEntries(
+ // inverter state
+ new AbstractMap.SimpleEntry("system-status", new UoM(Units.ONE, 1)),
+
+ // solar generation
+ new AbstractMap.SimpleEntry("pv-power", new UoM(Units.WATT, 10)),
+
+ // electric data for strings #1 and #2
+ new AbstractMap.SimpleEntry("pv1-voltage", new UoM(Units.VOLT, 10)),
+ new AbstractMap.SimpleEntry("pv1-current", new UoM(Units.AMPERE, 10)),
+ new AbstractMap.SimpleEntry("pv1-power", new UoM(Units.WATT, 10)),
+
+ new AbstractMap.SimpleEntry("pv2-voltage", new UoM(Units.VOLT, 10)),
+ new AbstractMap.SimpleEntry("pv2-current", new UoM(Units.AMPERE, 10)),
+ new AbstractMap.SimpleEntry("pv2-power", new UoM(Units.WATT, 10)),
+
+ // grid electric data (1-phase resp. 3-phase)
+ new AbstractMap.SimpleEntry("grid-frequency", new UoM(Units.HERTZ, 100)),
+
+ new AbstractMap.SimpleEntry("grid-voltage-r", new UoM(Units.VOLT, 10)),
+ new AbstractMap.SimpleEntry("grid-voltage-s", new UoM(Units.VOLT, 10)),
+ new AbstractMap.SimpleEntry("grid-voltage-t", new UoM(Units.VOLT, 10)),
+ new AbstractMap.SimpleEntry("grid-voltage-rs", new UoM(Units.VOLT, 10)),
+ new AbstractMap.SimpleEntry("grid-voltage-st", new UoM(Units.VOLT, 10)),
+ new AbstractMap.SimpleEntry("grid-voltage-tr", new UoM(Units.VOLT, 10)),
+
+ // inverter output
+ new AbstractMap.SimpleEntry("inverter-current-r", new UoM(Units.AMPERE, 10)),
+ new AbstractMap.SimpleEntry("inverter-current-s", new UoM(Units.AMPERE, 10)),
+ new AbstractMap.SimpleEntry("inverter-current-t", new UoM(Units.AMPERE, 10)),
+
+ new AbstractMap.SimpleEntry("inverter-power", new UoM(Units.WATT, 10)),
+ new AbstractMap.SimpleEntry("inverter-power-r", new UoM(Units.WATT, 10)),
+ new AbstractMap.SimpleEntry("inverter-power-s", new UoM(Units.WATT, 10)),
+ new AbstractMap.SimpleEntry("inverter-power-t", new UoM(Units.WATT, 10)),
+
+ new AbstractMap.SimpleEntry("inverter-va", new UoM(Units.VOLT_AMPERE, 10)),
+
+ // battery discharge / charge power
+ new AbstractMap.SimpleEntry("charge-current", new UoM(Units.AMPERE, 10)),
+ new AbstractMap.SimpleEntry("charge-power", new UoM(Units.WATT, 10)),
+
+ new AbstractMap.SimpleEntry("discharge-power", new UoM(Units.WATT, 10)),
+ new AbstractMap.SimpleEntry("discharge-va", new UoM(Units.VOLT_AMPERE, 10)),
+
+ // export power to grid
+ new AbstractMap.SimpleEntry("export-power", new UoM(Units.WATT, 10)),
+ new AbstractMap.SimpleEntry("export-power-r", new UoM(Units.WATT, 10)),
+ new AbstractMap.SimpleEntry("export-power-s", new UoM(Units.WATT, 10)),
+ new AbstractMap.SimpleEntry("export-power-t", new UoM(Units.WATT, 10)),
+
+ // power to user
+ new AbstractMap.SimpleEntry("import-power", new UoM(Units.WATT, 10)),
+ new AbstractMap.SimpleEntry("import-power-r", new UoM(Units.WATT, 10)),
+ new AbstractMap.SimpleEntry("import-power-s", new UoM(Units.WATT, 10)),
+ new AbstractMap.SimpleEntry("import-power-t", new UoM(Units.WATT, 10)),
+
+ // power to local
+ new AbstractMap.SimpleEntry("load-power", new UoM(Units.WATT, 10)),
+ new AbstractMap.SimpleEntry("load-power-r", new UoM(Units.WATT, 10)),
+ new AbstractMap.SimpleEntry("load-power-s", new UoM(Units.WATT, 10)),
+ new AbstractMap.SimpleEntry("load-power-t", new UoM(Units.WATT, 10)),
+
+ // inverter output energy
+ new AbstractMap.SimpleEntry("inverter-energy-today", new UoM(Units.KILOWATT_HOUR, 10)),
+ new AbstractMap.SimpleEntry("inverter-energy-total", new UoM(Units.KILOWATT_HOUR, 10)),
+
+ // solar DC input energy
+ new AbstractMap.SimpleEntry("pv-energy-today", new UoM(Units.KILOWATT_HOUR, 10)),
+ new AbstractMap.SimpleEntry("pv1-energy-today", new UoM(Units.KILOWATT_HOUR, 10)),
+ new AbstractMap.SimpleEntry("pv2-energy-today", new UoM(Units.KILOWATT_HOUR, 10)),
+
+ new AbstractMap.SimpleEntry("pv-energy-total", new UoM(Units.KILOWATT_HOUR, 10)),
+ new AbstractMap.SimpleEntry("pv1-energy-total", new UoM(Units.KILOWATT_HOUR, 10)),
+ new AbstractMap.SimpleEntry("pv2-energy-total", new UoM(Units.KILOWATT_HOUR, 10)),
+
+ // energy exported to grid
+ new AbstractMap.SimpleEntry("export-energy-today", new UoM(Units.KILOWATT_HOUR, 10)),
+ new AbstractMap.SimpleEntry("export-energy-total", new UoM(Units.KILOWATT_HOUR, 10)),
+
+ // energy imported from grid
+ new AbstractMap.SimpleEntry("import-energy-today", new UoM(Units.KILOWATT_HOUR, 10)),
+ new AbstractMap.SimpleEntry("import-energy-total", new UoM(Units.KILOWATT_HOUR, 10)),
+
+ // energy supplied to load
+ new AbstractMap.SimpleEntry("load-energy-today", new UoM(Units.KILOWATT_HOUR, 10)),
+ new AbstractMap.SimpleEntry("load-energy-total", new UoM(Units.KILOWATT_HOUR, 10)),
+
+ // energy imported to charge
+ new AbstractMap.SimpleEntry("import-charge-energy-today", new UoM(Units.KILOWATT_HOUR, 10)),
+ new AbstractMap.SimpleEntry("import-charge-energy-total", new UoM(Units.KILOWATT_HOUR, 10)),
+
+ // inverter energy to charge
+ new AbstractMap.SimpleEntry("inverter-charge-energy-today", new UoM(Units.KILOWATT_HOUR, 10)),
+ new AbstractMap.SimpleEntry("inverter-charge-energy-total", new UoM(Units.KILOWATT_HOUR, 10)),
+
+ // energy supplied from discharge
+ new AbstractMap.SimpleEntry("discharge-energy-today", new UoM(Units.KILOWATT_HOUR, 10)),
+ new AbstractMap.SimpleEntry("discharge-energy-total", new UoM(Units.KILOWATT_HOUR, 10)),
+
+ // inverter up time
+ new AbstractMap.SimpleEntry("total-work-time", new UoM(Units.HOUR, 7200)),
+
+ // bus voltages
+ new AbstractMap.SimpleEntry("p-bus-voltage", new UoM(Units.VOLT, 10)),
+ new AbstractMap.SimpleEntry("n-bus-voltage", new UoM(Units.VOLT, 10)),
+ new AbstractMap.SimpleEntry("sp-bus-voltage", new UoM(Units.VOLT, 10)),
+
+ // temperatures
+ new AbstractMap.SimpleEntry("pv-temperature", new UoM(SIUnits.CELSIUS, 10)),
+ new AbstractMap.SimpleEntry("pv-ipm-temperature", new UoM(SIUnits.CELSIUS, 10)),
+ new AbstractMap.SimpleEntry("pv-boost-temperature", new UoM(SIUnits.CELSIUS, 10)),
+ new AbstractMap.SimpleEntry("temperature-4", new UoM(SIUnits.CELSIUS, 10)),
+ new AbstractMap.SimpleEntry("pv2-temperature", new UoM(SIUnits.CELSIUS, 10)),
+
+ // battery data
+ new AbstractMap.SimpleEntry("battery-type", new UoM(Units.ONE, 1)),
+ new AbstractMap.SimpleEntry("battery-voltage", new UoM(Units.VOLT, 10)),
+ new AbstractMap.SimpleEntry("battery-temperature", new UoM(SIUnits.CELSIUS, 10)),
+ new AbstractMap.SimpleEntry("battery-display", new UoM(Units.ONE, 10)),
+ new AbstractMap.SimpleEntry("battery-soc", new UoM(Units.PERCENT, 1)),
+
+ // fault codes
+ new AbstractMap.SimpleEntry("system-fault-0", new UoM(Units.ONE, 1)),
+ new AbstractMap.SimpleEntry("system-fault-1", new UoM(Units.ONE, 1)),
+ new AbstractMap.SimpleEntry("system-fault-2", new UoM(Units.ONE, 1)),
+ new AbstractMap.SimpleEntry("system-fault-3", new UoM(Units.ONE, 1)),
+ new AbstractMap.SimpleEntry("system-fault-4", new UoM(Units.ONE, 1)),
+ new AbstractMap.SimpleEntry("system-fault-5", new UoM(Units.ONE, 1)),
+ new AbstractMap.SimpleEntry("system-fault-6", new UoM(Units.ONE, 1)),
+ new AbstractMap.SimpleEntry("system-fault-7", new UoM(Units.ONE, 1)),
+
+ // miscellaneous
+ new AbstractMap.SimpleEntry("system-work-mode", new UoM(Units.ONE, 1)),
+ new AbstractMap.SimpleEntry("sp-display-status", new UoM(Units.ONE, 10)),
+ new AbstractMap.SimpleEntry("constant-power-ok", new UoM(Units.ONE, 1)),
+ new AbstractMap.SimpleEntry("load-percent", new UoM(Units.PERCENT, 10)),
+
+ // reactive 'power' resp. 'energy'
+ new AbstractMap.SimpleEntry("rac", new UoM(Units.VAR, 10)),
+ new AbstractMap.SimpleEntry("erac-today", new UoM(Units.KILOVAR_HOUR, 10)),
+ new AbstractMap.SimpleEntry("erac-total", new UoM(Units.KILOVAR_HOUR, 10))
+ //
+ );
+
+ public static Map getMap() {
+ return GrowattChannels.CHANNEL_ID_UOM_MAP;
+ }
+}
diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/action/GrowattActions.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/action/GrowattActions.java
new file mode 100644
index 00000000000..bd55c04a079
--- /dev/null
+++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/action/GrowattActions.java
@@ -0,0 +1,76 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.growatt.internal.action;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.growatt.internal.handler.GrowattInverterHandler;
+import org.openhab.core.automation.annotation.ActionInput;
+import org.openhab.core.automation.annotation.RuleAction;
+import org.openhab.core.thing.binding.ThingActions;
+import org.openhab.core.thing.binding.ThingActionsScope;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Implementation of the {@link ThingActions} interface used for setting up battery charging and discharging programs.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ */
+@ThingActionsScope(name = "growatt")
+@NonNullByDefault
+public class GrowattActions implements ThingActions {
+
+ private final Logger logger = LoggerFactory.getLogger(GrowattActions.class);
+ private @Nullable GrowattInverterHandler handler;
+
+ public static void setupBatteryProgram(ThingActions actions, Integer programMode, @Nullable Integer powerLevel,
+ @Nullable Integer stopSOC, @Nullable Boolean enableAcCharging, @Nullable String startTime,
+ @Nullable String stopTime, @Nullable Boolean enableProgram) {
+ if (actions instanceof GrowattActions growattActions) {
+ growattActions.setupBatteryProgram(programMode, powerLevel, stopSOC, enableAcCharging, startTime, stopTime,
+ enableProgram);
+ } else {
+ throw new IllegalArgumentException("The 'actions' argument is not an instance of GrowattActions");
+ }
+ }
+
+ @Override
+ public @Nullable ThingHandler getThingHandler() {
+ return handler;
+ }
+
+ @Override
+ public void setThingHandler(@Nullable ThingHandler handler) {
+ this.handler = (handler instanceof GrowattInverterHandler growattHandler) ? growattHandler : null;
+ }
+
+ @RuleAction(label = "@text/actions.battery-program.label", description = "@text/actions.battery-program.description")
+ public void setupBatteryProgram(
+ @ActionInput(name = "program-mode", label = "@text/actions.program-mode.label", description = "@text/actions.program-mode.description") Integer programMode,
+ @ActionInput(name = "power-level", label = "@text/actions.power-level.label", description = "@text/actions.power-level.description") @Nullable Integer powerLevel,
+ @ActionInput(name = "stop-soc", label = "@text/actions.stop-soc.label", description = "@text/actions.stop-soc.description") @Nullable Integer stopSOC,
+ @ActionInput(name = "enable-ac-charging", label = "@text/actions.enable-ac-charging.label", description = "@text/actions.enable-ac-charging.description") @Nullable Boolean enableAcCharging,
+ @ActionInput(name = "start-time", label = "@text/actions.start-time.label", description = "@text/actions.start-time.description") @Nullable String startTime,
+ @ActionInput(name = "stop-time", label = "@text/actions.stop-time.label", description = "@text/actions.stop-time.description") @Nullable String stopTime,
+ @ActionInput(name = "enable-program", label = "@text/actions.enable-program.label", description = "@text/actions.enable-program.description") @Nullable Boolean enableProgram) {
+ GrowattInverterHandler handler = this.handler;
+ if (handler != null) {
+ handler.setupBatteryProgram(programMode, powerLevel, stopSOC, enableAcCharging, startTime, stopTime,
+ enableProgram);
+ } else {
+ logger.warn("ThingHandler is null.");
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/cloud/GrowattApiException.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/cloud/GrowattApiException.java
new file mode 100644
index 00000000000..3ab477cb1ad
--- /dev/null
+++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/cloud/GrowattApiException.java
@@ -0,0 +1,34 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.growatt.internal.cloud;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link GrowattApiException} is thrown if a call to the Growatt cloud API server fails.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution.
+ */
+@NonNullByDefault
+public class GrowattApiException extends Exception {
+
+ private static final long serialVersionUID = 218139823621683189L;
+
+ public GrowattApiException(String message) {
+ super(message);
+ }
+
+ public GrowattApiException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/cloud/GrowattCloud.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/cloud/GrowattCloud.java
new file mode 100644
index 00000000000..4d3eeaa758d
--- /dev/null
+++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/cloud/GrowattCloud.java
@@ -0,0 +1,904 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.growatt.internal.cloud;
+
+import java.lang.reflect.Type;
+import java.net.HttpCookie;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.DateTimeException;
+import java.time.Duration;
+import java.time.LocalTime;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.api.ContentResponse;
+import org.eclipse.jetty.client.api.Request;
+import org.eclipse.jetty.client.util.FormContentProvider;
+import org.eclipse.jetty.http.HttpMethod;
+import org.eclipse.jetty.http.HttpStatus;
+import org.eclipse.jetty.util.Fields;
+import org.openhab.binding.growatt.internal.GrowattBindingConstants;
+import org.openhab.binding.growatt.internal.config.GrowattBridgeConfiguration;
+import org.openhab.binding.growatt.internal.dto.GrowattDevice;
+import org.openhab.binding.growatt.internal.dto.GrowattPlant;
+import org.openhab.binding.growatt.internal.dto.GrowattPlantList;
+import org.openhab.binding.growatt.internal.dto.GrowattUser;
+import org.openhab.core.io.net.http.HttpClientFactory;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.google.gson.JsonPrimitive;
+import com.google.gson.JsonSyntaxException;
+import com.google.gson.reflect.TypeToken;
+
+/**
+ * The {@link GrowattCloud} class allows the binding to access the inverter state and settings via HTTP calls to the
+ * remote Growatt cloud API server (instead of receiving the data from the local Grott proxy server).
+ *
+ * This class is necessary since the Grott proxy server does not (yet) support easy access to some inverter register
+ * settings, such as the settings for the battery charging and discharging programs.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ */
+@NonNullByDefault
+public class GrowattCloud implements AutoCloseable {
+
+ // JSON field names for the battery charging program
+ public static final String CHARGE_PROGRAM_POWER = "chargePowerCommand";
+ public static final String CHARGE_PROGRAM_TARGET_SOC = "wchargeSOCLowLimit2";
+ public static final String CHARGE_PROGRAM_ALLOW_AC_CHARGING = "acChargeEnable";
+ public static final String CHARGE_PROGRAM_START_TIME = "forcedChargeTimeStart1";
+ public static final String CHARGE_PROGRAM_STOP_TIME = "forcedChargeTimeStop1";
+ public static final String CHARGE_PROGRAM_ENABLE = "forcedChargeStopSwitch1";
+
+ // JSON field names for the battery discharging program
+ public static final String DISCHARGE_PROGRAM_POWER = "disChargePowerCommand";
+ public static final String DISCHARGE_PROGRAM_TARGET_SOC = "wdisChargeSOCLowLimit2";
+ public static final String DISCHARGE_PROGRAM_START_TIME = "forcedDischargeTimeStart1";
+ public static final String DISCHARGE_PROGRAM_STOP_TIME = "forcedDischargeTimeStop1";
+ public static final String DISCHARGE_PROGRAM_ENABLE = "forcedDischargeStopSwitch1";
+
+ // API server URL
+ private static final String SERVER_URL = "https://server-api.growatt.com/";
+
+ // API end points
+ private static final String LOGIN_API_ENDPOINT = "newTwoLoginAPI.do";
+ private static final String PLANT_LIST_API_ENDPOINT = "PlantListAPI.do";
+ private static final String PLANT_INFO_API_ENDPOINT = "newTwoPlantAPI.do";
+ private static final String NEW_TCP_SET_API_ENDPOINT = "newTcpsetAPI.do";
+
+ private static final String FMT_NEW_DEVICE_TYPE_API_DO = "new%sApi.do";
+
+ // command operations
+ private static final String OP_GET_ALL_DEVICE_LIST = "getAllDeviceList";
+
+ // enum of device types
+ private static enum DeviceType {
+ MIX,
+ MAX,
+ MIN,
+ SPA,
+ SPH,
+ TLX
+ }
+
+ /*
+ * Map of device types vs. field parameters for GET requests to FMT_NEW_DEVICE_TYPE_API_DO end-points.
+ * Note: some values are guesses which have not yet been confirmed by users
+ */
+ private static final Map SUPPORTED_TYPES_GET_PARAM = Map.of(
+ // @formatter:off
+ DeviceType.MIX, "getMixSetParams",
+ DeviceType.MAX, "getMaxSetData",
+ DeviceType.MIN, "getMinSetData",
+ DeviceType.SPA, "getSpaSetData",
+ DeviceType.SPH, "getSphSetData",
+ DeviceType.TLX, "getTlxSetData"
+ // @formatter:on
+ );
+
+ /*
+ * Map of device types vs. field parameters for POST commands to NEW_TCP_SET_API_ENDPOINT.
+ * Note: some values are guesses which have not yet been confirmed by users
+ */
+ private static final Map SUPPORTED_TYPE_POST_PARAM = Map.of(
+ // @formatter:off
+ DeviceType.MIX, "mixSetApiNew", // was "mixSetApi"
+ DeviceType.MAX, "maxSetApi",
+ DeviceType.MIN, "minSetApi",
+ DeviceType.SPA, "spaSetApi",
+ DeviceType.SPH, "sphSet",
+ DeviceType.TLX, "tlxSet"
+ // @formatter:on
+ );
+
+ // enum to select charge resp. discharge program
+ private static enum ProgramType {
+ CHARGE,
+ DISCHARGE
+ }
+
+ // enum of program modes
+ public static enum ProgramMode {
+ LOAD_FIRST,
+ BATTERY_FIRST,
+ GRID_FIRST
+ }
+
+ // @formatter:off
+ private static final Type DEVICE_LIST_TYPE = new TypeToken>() {}.getType();
+ // @formatter:on
+
+ // HTTP headers (user agent is spoofed to mimic the Growatt Android Shine app)
+ private static final String USER_AGENT = "Dalvik/2.1.0 (Linux; U; Android 12; https://www.openhab.org)";
+ private static final String FORM_CONTENT = "application/x-www-form-urlencoded";
+
+ private static final Duration HTTP_TIMEOUT = Duration.ofSeconds(10);
+
+ private final Logger logger = LoggerFactory.getLogger(GrowattCloud.class);
+ private final HttpClient httpClient;
+ private final GrowattBridgeConfiguration configuration;
+ private final Gson gson = new Gson();
+ private final List plantIds = new ArrayList<>();
+ private final Map deviceIdTypeMap = new ConcurrentHashMap<>();
+
+ private String userId = "";
+
+ /**
+ * Constructor.
+ *
+ * @param configuration the bridge configuration parameters.
+ * @param httpClientFactory the OH core {@link HttpClientFactory} instance.
+ * @throws Exception if anything goes wrong.
+ */
+ public GrowattCloud(GrowattBridgeConfiguration configuration, HttpClientFactory httpClientFactory)
+ throws Exception {
+ this.configuration = configuration;
+ this.httpClient = httpClientFactory.createHttpClient(GrowattBindingConstants.BINDING_ID);
+ this.httpClient.start();
+ }
+
+ @Override
+ public void close() throws Exception {
+ httpClient.stop();
+ }
+
+ /**
+ * Create a hash of the given password using normal MD5, except add 'c' if a byte of the digest is less than 10
+ *
+ * @param password the plain text password
+ * @return the hash of the password
+ * @throws GrowattApiException if MD5 algorithm is not supported
+ */
+ private static String createHash(String password) throws GrowattApiException {
+ MessageDigest md;
+ try {
+ md = MessageDigest.getInstance("MD5");
+ } catch (NoSuchAlgorithmException e) {
+ throw new GrowattApiException("Hash algorithm error", e);
+ }
+ byte[] bytes = md.digest(password.getBytes());
+ StringBuilder result = new StringBuilder();
+ for (byte b : bytes) {
+ result.append(String.format("%02x", b));
+ }
+ for (int i = 0; i < result.length(); i += 2) {
+ if (result.charAt(i) == '0') {
+ result.replace(i, i + 1, "c");
+ }
+ }
+ return result.toString();
+ }
+
+ /**
+ * Refresh the login cookies.
+ *
+ * @throws GrowattApiException if any error occurs.
+ */
+ private void refreshCookies() throws GrowattApiException {
+ List cookies = httpClient.getCookieStore().getCookies();
+ if (cookies.isEmpty() || cookies.stream().anyMatch(HttpCookie::hasExpired)) {
+ postLoginCredentials();
+ }
+ }
+
+ /**
+ * Login to the server (if necessary) and then execute an HTTP request using the given HTTP method, to the given end
+ * point, and with the given request URL parameters and/or request form fields. If the cookies are not valid first
+ * login to the server before making the actual HTTP request.
+ *
+ * @param method the HTTP method to use.
+ * @param endPoint the API end point.
+ * @param params the request URL parameters (may be null).
+ * @param fields the request form fields (may be null).
+ * @return a Map of JSON elements containing the server response.
+ * @throws GrowattApiException if any error occurs.
+ */
+ private Map doHttpRequest(HttpMethod method, String endPoint,
+ @Nullable Map params, @Nullable Fields fields) throws GrowattApiException {
+ refreshCookies();
+ return doHttpRequestInner(method, endPoint, params, fields);
+ }
+
+ /**
+ * Inner method to execute an HTTP request using the given HTTP method, to the given end point, and with the given
+ * request URL parameters and/or request form fields.
+ *
+ * @param method the HTTP method to use.
+ * @param endPoint the API end point.
+ * @param params the request URL parameters (may be null).
+ * @param fields the request form fields (may be null).
+ * @return a Map of JSON elements containing the server response.
+ * @throws GrowattApiException if any error occurs.
+ */
+ private Map doHttpRequestInner(HttpMethod method, String endPoint,
+ @Nullable Map params, @Nullable Fields fields) throws GrowattApiException {
+ //
+ Request request = httpClient.newRequest(SERVER_URL + endPoint).method(method).agent(USER_AGENT)
+ .timeout(HTTP_TIMEOUT.getSeconds(), TimeUnit.SECONDS);
+
+ if (params != null) {
+ params.entrySet().forEach(p -> request.param(p.getKey(), p.getValue()));
+ }
+
+ if (fields != null) {
+ request.content(new FormContentProvider(fields), FORM_CONTENT);
+ }
+
+ if (logger.isTraceEnabled()) {
+ logger.trace("{} {}{} {} {}", method, request.getPath(), params == null ? "" : "?" + request.getQuery(),
+ request.getVersion(), fields == null ? "" : "? " + FormContentProvider.convert(fields));
+ }
+
+ ContentResponse response;
+ try {
+ response = request.send();
+ } catch (InterruptedException | TimeoutException | ExecutionException e) {
+ throw new GrowattApiException("HTTP I/O Exception", e);
+ }
+
+ int status = response.getStatus();
+ String content = response.getContentAsString();
+
+ logger.trace("HTTP {} {} {}", status, HttpStatus.getMessage(status), content);
+
+ if (status != HttpStatus.OK_200) {
+ throw new GrowattApiException(String.format("HTTP %d %s", status, HttpStatus.getMessage(status)));
+ }
+
+ if (content == null || content.isBlank()) {
+ throw new GrowattApiException("Response is " + (content == null ? "null" : "blank"));
+ }
+
+ if (content.contains("")) {
+ logger.warn("HTTP {} {} {}", status, HttpStatus.getMessage(status), content);
+ throw new GrowattApiException("Response is HTML");
+ }
+
+ try {
+ JsonElement jsonObject = JsonParser.parseString(content).getAsJsonObject();
+ if (jsonObject instanceof JsonObject jsonElement) {
+ return jsonElement.asMap();
+ }
+ throw new GrowattApiException("Response JSON invalid");
+ } catch (JsonSyntaxException | IllegalStateException e) {
+ throw new GrowattApiException("Response JSON syntax exception", e);
+ }
+ }
+
+ /**
+ * Get the deviceType for the given deviceId. If the deviceIdTypeMap is empty then download it freshly.
+ *
+ * @param the deviceId to get.
+ * @return the deviceType.
+ * @throws GrowattApiException if any error occurs.
+ */
+ private DeviceType getDeviceTypeChecked(String deviceId) throws GrowattApiException {
+ if (deviceIdTypeMap.isEmpty()) {
+ if (plantIds.isEmpty()) {
+ refreshCookies();
+ }
+ for (String plantId : plantIds) {
+ for (GrowattDevice device : getPlantInfo(plantId)) {
+ try {
+ deviceIdTypeMap.put(device.getId(), DeviceType.valueOf(device.getType().toUpperCase()));
+ } catch (IllegalArgumentException e) {
+ // just ignore unsupported device types
+ }
+ }
+ }
+ logger.debug("Downloaded deviceTypes:{}", deviceIdTypeMap);
+ }
+ if (deviceId.isBlank()) {
+ throw new GrowattApiException("Device id is blank");
+ }
+ DeviceType deviceType = deviceIdTypeMap.get(deviceId);
+ if (deviceType != null) {
+ return deviceType;
+ }
+ throw new GrowattApiException("Unsupported device:" + deviceId);
+ }
+
+ /**
+ * Get the inverter device settings.
+ *
+ * @param the deviceId to get.
+ * @return a Map of JSON elements containing the server response.
+ * @throws GrowattApiException if any error occurs.
+ */
+ public Map getDeviceSettings(String deviceId) throws GrowattApiException {
+ DeviceType deviceType = getDeviceTypeChecked(deviceId);
+ String dt = deviceType.name().toLowerCase();
+
+ String endPoint = String.format(FMT_NEW_DEVICE_TYPE_API_DO, dt.substring(0, 1).toUpperCase() + dt.substring(1));
+
+ Map params = new LinkedHashMap<>(); // keep params in order
+ params.put("op", Objects.requireNonNull(SUPPORTED_TYPES_GET_PARAM.get(deviceType)));
+ params.put("serialNum", deviceId);
+ params.put("kind", "0");
+
+ Map result = doHttpRequest(HttpMethod.GET, endPoint, params, null);
+
+ JsonElement obj = result.get("obj");
+ if (obj instanceof JsonObject object) {
+ Map map = object.asMap();
+ Optional key = map.keySet().stream().filter(k -> k.toLowerCase().endsWith("bean")).findFirst();
+ if (key.isPresent()) {
+ JsonElement beanJson = map.get(key.get());
+ if (beanJson instanceof JsonObject bean) {
+ return bean.asMap();
+ }
+ }
+ }
+ throw new GrowattApiException("Invalid JSON response");
+ }
+
+ /**
+ * Get the plant information.
+ *
+ * @param the plantId to get.
+ * @return a list of {@link GrowattDevice} containing the server response.
+ * @throws GrowattApiException if any error occurs.
+ */
+ public List getPlantInfo(String plantId) throws GrowattApiException {
+ Map params = new LinkedHashMap<>(); // keep params in order
+ params.put("op", OP_GET_ALL_DEVICE_LIST);
+ params.put("plantId", plantId);
+ params.put("pageNum", "1");
+ params.put("pageSize", "1");
+
+ Map result = doHttpRequest(HttpMethod.GET, PLANT_INFO_API_ENDPOINT, params, null);
+
+ JsonElement deviceList = result.get("deviceList");
+ if (deviceList instanceof JsonArray deviceArray) {
+ try {
+ List devices = gson.fromJson(deviceArray, DEVICE_LIST_TYPE);
+ if (devices != null) {
+ return devices;
+ }
+ } catch (JsonSyntaxException e) {
+ // fall through
+ }
+ }
+ throw new GrowattApiException("Invalid JSON response");
+ }
+
+ /**
+ * Get the plant list.
+ *
+ * @param the userId to get from.
+ * @return a {@link GrowattPlantList} containing the server response.
+ * @throws GrowattApiException if any error occurs.
+ */
+ public GrowattPlantList getPlantList(String userId) throws GrowattApiException {
+ Map params = new LinkedHashMap<>(); // keep params in order
+ params.put("userId", userId);
+
+ Map result = doHttpRequest(HttpMethod.GET, PLANT_LIST_API_ENDPOINT, params, null);
+
+ JsonElement back = result.get("back");
+ if (back instanceof JsonObject backObject) {
+ try {
+ GrowattPlantList plantList = gson.fromJson(backObject, GrowattPlantList.class);
+ if (plantList != null && plantList.getSuccess()) {
+ return plantList;
+ }
+ } catch (JsonSyntaxException e) {
+ // fall through
+ }
+ }
+ throw new GrowattApiException("Invalid JSON response");
+ }
+
+ /**
+ * Attempt to login to the remote server by posting the given user credentials.
+ *
+ * @throws GrowattApiException if any error occurs.
+ */
+ private void postLoginCredentials() throws GrowattApiException {
+ String userName = configuration.userName;
+ if (userName == null || userName.isBlank()) {
+ throw new GrowattApiException("User name missing");
+ }
+ String password = configuration.password;
+ if (password == null || password.isBlank()) {
+ throw new GrowattApiException("Password missing");
+ }
+
+ Fields fields = new Fields();
+ fields.put("userName", userName);
+ fields.put("password", createHash(password));
+
+ Map result = doHttpRequestInner(HttpMethod.POST, LOGIN_API_ENDPOINT, null, fields);
+
+ JsonElement back = result.get("back");
+ if (back instanceof JsonObject backObject) {
+ try {
+ GrowattPlantList plantList = gson.fromJson(backObject, GrowattPlantList.class);
+ if (plantList != null && plantList.getSuccess()) {
+ GrowattUser user = plantList.getUserId();
+ userId = user != null ? user.getId() : userId;
+ plantIds.clear();
+ plantIds.addAll(plantList.getPlants().stream().map(GrowattPlant::getId).toList());
+ logger.debug("Logged in userId:{}, plantIds:{}", userId, plantIds);
+ return;
+ }
+ } catch (JsonSyntaxException e) {
+ // fall through
+ }
+ }
+ throw new GrowattApiException("Login failed");
+ }
+
+ /**
+ * Post a command to setup the inverter battery charging program.
+ *
+ * @param the deviceId to set up
+ * @param programModeInt index of the type of program Load First (0) / Battery First (1) / Grid First (2)
+ * @param powerLevel the rate of charging / discharging
+ * @param stopSOC the SOC at which to stop charging / discharging
+ * @param enableAcCharging allow charging from AC power
+ * @param startTime the start time of the charging / discharging program
+ * @param stopTime the stop time of the charging / discharging program
+ * @param enableProgram charging / discharging program shall be enabled
+ *
+ * @throws GrowattApiException if any error occurs
+ */
+ public void setupBatteryProgram(String deviceId, int programModeInt, @Nullable Integer powerLevel,
+ @Nullable Integer stopSOC, @Nullable Boolean enableAcCharging, @Nullable String startTime,
+ @Nullable String stopTime, @Nullable Boolean enableProgram) throws GrowattApiException {
+ //
+ if (deviceId.isBlank()) {
+ throw new GrowattApiException("Device id is blank");
+ }
+
+ ProgramMode programMode;
+ try {
+ programMode = ProgramMode.values()[programModeInt];
+ } catch (IndexOutOfBoundsException e) {
+ throw new GrowattApiException("Program mode is out of range (0..2)");
+ }
+
+ DeviceType deviceType = getDeviceTypeChecked(deviceId);
+ switch (deviceType) {
+
+ case MIX:
+ case SPA:
+ setTimeProgram(deviceId, deviceType,
+ programMode == ProgramMode.BATTERY_FIRST ? ProgramType.CHARGE : ProgramType.DISCHARGE,
+ powerLevel, stopSOC, enableAcCharging, startTime, stopTime, enableProgram);
+ return;
+
+ case TLX:
+ if (enableAcCharging != null) {
+ setEnableAcCharging(deviceId, deviceType, enableAcCharging);
+ }
+ if (powerLevel != null) {
+ setPowerLevel(deviceId, deviceType, programMode, powerLevel);
+ }
+ if (stopSOC != null) {
+ setStopSOC(deviceId, deviceType, programMode, stopSOC);
+ }
+ if (startTime != null || stopTime != null || enableProgram != null) {
+ setTimeSegment(deviceId, deviceType, programMode, startTime, stopTime, enableProgram);
+ }
+ return;
+
+ default:
+ }
+ throw new GrowattApiException("Unsupported device type:" + deviceType.name());
+ }
+
+ /**
+ * Look for an entry in the given Map, and return its value as a boolean.
+ *
+ * @param map the source map.
+ * @param key the key to search for in the map.
+ * @return the boolean value.
+ * @throws GrowattApiException if any error occurs.
+ */
+ public static boolean mapGetBoolean(Map map, String key) throws GrowattApiException {
+ JsonElement element = map.get(key);
+ if (element instanceof JsonPrimitive primitive) {
+ if (primitive.isBoolean()) {
+ return primitive.getAsBoolean();
+ } else if (primitive.isNumber() || primitive.isString()) {
+ try {
+ switch (primitive.getAsInt()) {
+ case 0:
+ return false;
+ case 1:
+ return true;
+ }
+ } catch (NumberFormatException e) {
+ throw new GrowattApiException("Boolean bad value", e);
+ }
+ }
+ }
+ throw new GrowattApiException("Boolean missing or bad value");
+ }
+
+ /**
+ * Look for an entry in the given Map, and return its value as an integer.
+ *
+ * @param map the source map.
+ * @param key the key to search for in the map.
+ * @return the integer value.
+ * @throws GrowattApiException if any error occurs.
+ */
+ public static int mapGetInteger(Map map, String key) throws GrowattApiException {
+ JsonElement element = map.get(key);
+ if (element instanceof JsonPrimitive primitive) {
+ try {
+ return primitive.getAsInt();
+ } catch (NumberFormatException e) {
+ throw new GrowattApiException("Integer bad value", e);
+ }
+ }
+ throw new GrowattApiException("Integer missing or bad value");
+ }
+
+ /**
+ * Look for an entry in the given Map, and return its value as a LocalTime.
+ *
+ * @param source the source map.
+ * @param key the key to search for in the map.
+ * @return the LocalTime.
+ * @throws GrowattApiException if any error occurs.
+ */
+ public static LocalTime mapGetLocalTime(Map source, String key) throws GrowattApiException {
+ JsonElement element = source.get(key);
+ if ((element instanceof JsonPrimitive primitive) && primitive.isString()) {
+ try {
+ return localTimeOf(primitive.getAsString());
+ } catch (DateTimeException e) {
+ throw new GrowattApiException("LocalTime bad value", e);
+ }
+ }
+ throw new GrowattApiException("LocalTime missing or bad value");
+ }
+
+ /**
+ * Parse a time formatted string into a LocalTime entity.
+ *
+ * Note: unlike the standard LocalTime.parse() method, this method accepts hour and minute fields from the Growatt
+ * server that are without leading zeros e.g. "1:1" and it accepts the conventional "01:01" format too.
+ *
+ * @param localTime a time formatted string e.g. "12:34"
+ * @return a corresponding LocalTime entity.
+ * @throws DateTimeException if any error occurs.
+ */
+ public static LocalTime localTimeOf(String localTime) throws DateTimeException {
+ String splitParts[] = localTime.split(":");
+ if (splitParts.length < 2) {
+ throw new DateTimeException("LocalTime bad value");
+ }
+ try {
+ return LocalTime.of(Integer.valueOf(splitParts[0]), Integer.valueOf(splitParts[1]));
+ } catch (NumberFormatException | DateTimeException e) {
+ throw new DateTimeException("LocalTime bad value", e);
+ }
+ }
+
+ /**
+ * Post a command to set up the inverter battery charging / discharging program.
+ *
+ * @param the deviceId to set up
+ * @param the deviceType to set up
+ * @param programType selects whether the program is for charge or discharge
+ * @param powerLevel the rate of charging / discharging 1%..100%
+ * @param stopSOC the SOC at which to stop the program 5%..100%
+ * @param enableAcCharging allow charging from AC power (only applies to hybrid/mix inverters)
+ * @param startTime the start time of the program
+ * @param stopTime the stop time of the program
+ * @param enableProgram the program shall be enabled
+ *
+ * @throws GrowattApiException if any error occurs
+ */
+ private void setTimeProgram(String deviceId, DeviceType deviceType, ProgramType programType,
+ @Nullable Integer powerLevel, @Nullable Integer stopSOC, @Nullable Boolean enableAcCharging,
+ @Nullable String startTime, @Nullable String stopTime, @Nullable Boolean enableProgram)
+ throws GrowattApiException {
+ //
+ if (powerLevel == null || powerLevel < 1 || powerLevel > 100) {
+ throw new GrowattApiException("Power level parameter is null or out of range (1%..100%)");
+ }
+ if (stopSOC == null || stopSOC < 5 || stopSOC > 100) {
+ throw new GrowattApiException("Target SOC parameter is null out of range (5%..100%)");
+ }
+ if (startTime == null) {
+ throw new GrowattApiException("Start time parameter is null");
+ }
+ if (stopTime == null) {
+ throw new GrowattApiException("Stop time parameter is null");
+ }
+ if (enableProgram == null) {
+ throw new GrowattApiException("Program enable parameter is null");
+ }
+ boolean isMixChargeCommand = deviceType == DeviceType.MIX && programType == ProgramType.CHARGE;
+ if (isMixChargeCommand && enableAcCharging == null) {
+ throw new GrowattApiException("Allow ac charging parameter is null");
+ }
+ LocalTime localStartTime;
+ try {
+ localStartTime = GrowattCloud.localTimeOf(startTime);
+ } catch (DateTimeException e) {
+ throw new GrowattApiException("Start time is invalid");
+ }
+ LocalTime localStopTime;
+ try {
+ localStopTime = GrowattCloud.localTimeOf(stopTime);
+ } catch (DateTimeException e) {
+ throw new GrowattApiException("Stop time is invalid");
+ }
+
+ Fields fields = new Fields();
+
+ fields.put("op", Objects.requireNonNull(SUPPORTED_TYPE_POST_PARAM.get(deviceType)));
+ fields.put("serialNum", deviceId);
+ fields.put("type", String.format("%s_ac_%s_time_period", deviceType.name().toLowerCase(),
+ programType.name().toLowerCase()));
+
+ int paramId = 1;
+
+ paramId = addParam(fields, paramId, String.format("%d", powerLevel));
+ paramId = addParam(fields, paramId, String.format("%d", stopSOC));
+ if (isMixChargeCommand) {
+ paramId = addParam(fields, paramId, enableAcCharging ? "1" : "0");
+ }
+ paramId = addParam(fields, paramId, String.format("%02d", localStartTime.getHour()));
+ paramId = addParam(fields, paramId, String.format("%02d", localStartTime.getMinute()));
+ paramId = addParam(fields, paramId, String.format("%02d", localStopTime.getHour()));
+ paramId = addParam(fields, paramId, String.format("%02d", localStopTime.getMinute()));
+ paramId = addParam(fields, paramId, enableProgram ? "1" : "0");
+ paramId = addParam(fields, paramId, "00");
+ paramId = addParam(fields, paramId, "00");
+ paramId = addParam(fields, paramId, "00");
+ paramId = addParam(fields, paramId, "00");
+ paramId = addParam(fields, paramId, "0");
+ paramId = addParam(fields, paramId, "00");
+ paramId = addParam(fields, paramId, "00");
+ paramId = addParam(fields, paramId, "00");
+ paramId = addParam(fields, paramId, "00");
+ paramId = addParam(fields, paramId, "0");
+
+ postSetCommandForm(fields);
+ }
+
+ /**
+ * Add a new entry in the given {@link Fields} map in the form "paramN" = paramValue where N is the parameter index.
+ *
+ * @param fields the map to be added to.
+ * @param parameterIndex the parameter index.
+ * @param parameterValue the parameter value.
+ *
+ * @return the next parameter index.
+ */
+ private int addParam(Fields fields, int parameterIndex, String parameterValue) {
+ fields.put(String.format("param%d", parameterIndex), parameterValue);
+ return parameterIndex + 1;
+ }
+
+ /**
+ * Inner method to execute a POST setup command using the given form fields.
+ *
+ * @param fields the form fields to be posted.
+ *
+ * @throws GrowattApiException if any error occurs
+ */
+ private void postSetCommandForm(Fields fields) throws GrowattApiException {
+ Map result = doHttpRequest(HttpMethod.POST, NEW_TCP_SET_API_ENDPOINT, null, fields);
+ JsonElement success = result.get("success");
+ if (success instanceof JsonPrimitive sucessPrimitive) {
+ if (sucessPrimitive.getAsBoolean()) {
+ return;
+ }
+ }
+ throw new GrowattApiException("Command failed");
+ }
+
+ /**
+ * Post a command to enable / disable ac charging.
+ *
+ * @param the deviceId to set up
+ * @param the deviceType to set up
+ * @param enableAcCharging enable or disable the function
+ *
+ * @throws GrowattApiException if any error occurs
+ */
+ private void setEnableAcCharging(String deviceId, DeviceType deviceType, boolean enableAcCharging)
+ throws GrowattApiException {
+ //
+ Fields fields = new Fields();
+
+ fields.put("op", Objects.requireNonNull(SUPPORTED_TYPE_POST_PARAM.get(deviceType)));
+ fields.put("serialNum", deviceId);
+ fields.put("type", "ac_charge");
+ fields.put("param1", enableAcCharging ? "1" : "0");
+
+ postSetCommandForm(fields);
+ }
+
+ /**
+ * Post a command to set up a program charge / discharge power level.
+ *
+ * @param the deviceId to set up
+ * @param the deviceType to set up
+ * @param programMode the program mode that the setting shall apply to
+ * @param powerLevel the rate of charging / discharging 1%..100%
+ *
+ * @throws GrowattApiException if any error occurs
+ */
+ private void setPowerLevel(String deviceId, DeviceType deviceType, ProgramMode programMode, int powerLevel)
+ throws GrowattApiException {
+ //
+ if (powerLevel < 1 || powerLevel > 100) {
+ throw new GrowattApiException("Power level out of range (1%..100%)");
+ }
+
+ String typeParam;
+ switch (programMode) {
+ case BATTERY_FIRST:
+ typeParam = "charge_power";
+ break;
+ case GRID_FIRST:
+ case LOAD_FIRST:
+ typeParam = "discharge_power";
+ break;
+ default:
+ throw new GrowattApiException("Unexpected exception");
+ }
+
+ Fields fields = new Fields();
+
+ fields.put("op", Objects.requireNonNull(SUPPORTED_TYPE_POST_PARAM.get(deviceType)));
+ fields.put("serialNum", deviceId);
+ fields.put("type", typeParam);
+ fields.put("param1", String.format("%d", powerLevel));
+
+ postSetCommandForm(fields);
+ }
+
+ /**
+ * Post a command to set up a program target (stop) SOC level.
+ *
+ * @param the deviceId to set up
+ * @param the deviceType to set up
+ * @param programMode the program mode that the setting shall apply to
+ * @param stopSOC the SOC at which to stop the program 11%..100%
+ *
+ * @throws GrowattApiException if any error occurs
+ */
+ private void setStopSOC(String deviceId, DeviceType deviceType, ProgramMode programMode, int stopSOC)
+ throws GrowattApiException {
+ //
+ if (stopSOC < 11 || stopSOC > 100) {
+ throw new GrowattApiException("Target SOC out of range (11%..100%)");
+ }
+
+ String typeParam;
+ switch (programMode) {
+ case BATTERY_FIRST:
+ typeParam = "charge_stop_soc";
+ break;
+ case GRID_FIRST:
+ typeParam = "on_grid_discharge_stop_soc";
+ break;
+ case LOAD_FIRST:
+ typeParam = "discharge_stop_soc";
+ break;
+ default:
+ throw new GrowattApiException("Unexpected exception");
+ }
+
+ Fields fields = new Fields();
+
+ fields.put("op", Objects.requireNonNull(SUPPORTED_TYPE_POST_PARAM.get(deviceType)));
+ fields.put("serialNum", deviceId);
+ fields.put("type", typeParam);
+ fields.put("param1", String.format("%d", stopSOC));
+
+ postSetCommandForm(fields);
+ }
+
+ /**
+ * Post a command to set up a time segment program.
+ * Note: uses separate dedicated time segments for Load First, Battery First, Grid First modes.
+ *
+ * @param the deviceId to set up
+ * @param the deviceType to set up
+ * @param programMode the program mode for the time segment
+ * @param startTime the start time of the program
+ * @param stopTime the stop time of the program
+ * @param enableProgram the program shall be enabled
+ *
+ * @throws GrowattApiException if any error occurs
+ */
+ private void setTimeSegment(String deviceId, DeviceType deviceType, ProgramMode programMode,
+ @Nullable String startTime, @Nullable String stopTime, @Nullable Boolean enableProgram)
+ throws GrowattApiException {
+ //
+ if (startTime == null) {
+ throw new GrowattApiException("Start time parameter is null");
+ }
+ if (stopTime == null) {
+ throw new GrowattApiException("Stop time parameter is null");
+ }
+ if (enableProgram == null) {
+ throw new GrowattApiException("Program enable parameter is null");
+ }
+ LocalTime localStartTime;
+ try {
+ localStartTime = GrowattCloud.localTimeOf(startTime);
+ } catch (DateTimeException e) {
+ throw new GrowattApiException("Start time is invalid");
+ }
+ LocalTime localStopTime;
+ try {
+ localStopTime = GrowattCloud.localTimeOf(stopTime);
+ } catch (DateTimeException e) {
+ throw new GrowattApiException("Stop time is invalid");
+ }
+
+ Fields fields = new Fields();
+
+ fields.put("op", Objects.requireNonNull(SUPPORTED_TYPE_POST_PARAM.get(deviceType)));
+ fields.put("serialNum", deviceId);
+ fields.put("type", String.format("time_segment%d", programMode.ordinal() + 1));
+ fields.put("param1", String.format("%d", programMode.ordinal()));
+ fields.put("param2", String.format("%02d", localStartTime.getHour()));
+ fields.put("param3", String.format("%02d", localStartTime.getMinute()));
+ fields.put("param4", String.format("%02d", localStopTime.getHour()));
+ fields.put("param5", String.format("%02d", localStopTime.getMinute()));
+ fields.put("param6", enableProgram ? "1" : "0");
+
+ postSetCommandForm(fields);
+ }
+}
diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/config/GrowattBridgeConfiguration.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/config/GrowattBridgeConfiguration.java
new file mode 100644
index 00000000000..c47f7fb0d9b
--- /dev/null
+++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/config/GrowattBridgeConfiguration.java
@@ -0,0 +1,31 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.growatt.internal.config;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * The {@link GrowattBridgeConfiguration} class contains fields mapping thing configuration parameters.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ */
+@NonNullByDefault
+public class GrowattBridgeConfiguration {
+
+ public static final String USER_NAME = "userName";
+ public static final String PASSWORD = "password";
+
+ public @Nullable String userName;
+ public @Nullable String password;
+}
diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/config/GrowattInverterConfiguration.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/config/GrowattInverterConfiguration.java
new file mode 100644
index 00000000000..88e0b4aa2cb
--- /dev/null
+++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/config/GrowattInverterConfiguration.java
@@ -0,0 +1,28 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.growatt.internal.config;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link GrowattInverterConfiguration} class contains fields mapping thing configuration parameters.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ */
+@NonNullByDefault
+public class GrowattInverterConfiguration {
+
+ public static final String DEVICE_ID = "deviceId";
+
+ public String deviceId = "";
+}
diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/discovery/GrowattDiscoveryService.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/discovery/GrowattDiscoveryService.java
new file mode 100644
index 00000000000..cc39b3b7aa9
--- /dev/null
+++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/discovery/GrowattDiscoveryService.java
@@ -0,0 +1,68 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.growatt.internal.discovery;
+
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.growatt.internal.GrowattBindingConstants;
+import org.openhab.binding.growatt.internal.config.GrowattInverterConfiguration;
+import org.openhab.core.config.discovery.AbstractDiscoveryService;
+import org.openhab.core.config.discovery.DiscoveryResult;
+import org.openhab.core.config.discovery.DiscoveryResultBuilder;
+import org.openhab.core.i18n.LocaleProvider;
+import org.openhab.core.i18n.TranslationProvider;
+import org.openhab.core.thing.ThingUID;
+
+/**
+ * The {@link GrowattDiscoveryService} does discovery for Growatt inverters.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ */
+@NonNullByDefault
+public class GrowattDiscoveryService extends AbstractDiscoveryService {
+
+ private final Map> bridgeInverterIds = new ConcurrentHashMap<>();
+
+ public GrowattDiscoveryService(TranslationProvider i18nProvider, LocaleProvider localeProvider)
+ throws IllegalArgumentException {
+ super(Set.of(GrowattBindingConstants.THING_TYPE_INVERTER), 5, false);
+ this.i18nProvider = i18nProvider;
+ this.localeProvider = localeProvider;
+ }
+
+ public void putInverters(ThingUID bridgeUID, Set inverterIds) {
+ if (inverterIds.isEmpty()) {
+ bridgeInverterIds.remove(bridgeUID);
+ } else {
+ bridgeInverterIds.put(bridgeUID, inverterIds);
+ startScan();
+ }
+ }
+
+ @Override
+ protected void startScan() {
+ bridgeInverterIds.forEach((bridgeUID, inverterIds) -> {
+ inverterIds.forEach(inverterId -> {
+ DiscoveryResult inverter = DiscoveryResultBuilder
+ .create(new ThingUID(GrowattBindingConstants.THING_TYPE_INVERTER, bridgeUID, inverterId))
+ .withBridge(bridgeUID).withLabel("@text/discovery.growatt-inverter")
+ .withProperty(GrowattInverterConfiguration.DEVICE_ID, inverterId)
+ .withRepresentationProperty(GrowattInverterConfiguration.DEVICE_ID).build();
+ thingDiscovered(inverter);
+ });
+ });
+ }
+}
diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrottDevice.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrottDevice.java
new file mode 100644
index 00000000000..140078d1b6a
--- /dev/null
+++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrottDevice.java
@@ -0,0 +1,47 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.growatt.internal.dto;
+
+import java.lang.reflect.Type;
+import java.util.ArrayList;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+import com.google.gson.annotations.SerializedName;
+import com.google.gson.reflect.TypeToken;
+
+/**
+ * The {@link GrottDevice} is a DTO containing data fields received from the Grott application.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ */
+@NonNullByDefault
+public class GrottDevice {
+
+ // @formatter:off
+ public static final Type GROTT_DEVICE_ARRAY = new TypeToken>() {}.getType();
+ // @formatter:on
+
+ private @Nullable @SerializedName("device") String deviceId;
+ private @Nullable GrottValues values;
+
+ public String getDeviceId() {
+ String deviceId = this.deviceId;
+ return deviceId != null ? deviceId : "unknown";
+ }
+
+ public @Nullable GrottValues getValues() {
+ return values;
+ }
+}
diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrottValues.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrottValues.java
new file mode 100644
index 00000000000..a8fc983b558
--- /dev/null
+++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrottValues.java
@@ -0,0 +1,188 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.growatt.internal.dto;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * The {@link GrottValues} is a DTO containing inverter value fields received from the Grott application.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ */
+@NonNullByDefault
+public class GrottValues {
+
+ /**
+ * Convert Java field name to openHAB channel id
+ */
+ public static String getChannelId(String fieldName) {
+ return fieldName.replace("_", "-");
+ }
+
+ /**
+ * Convert openHAB channel id to Java field name
+ */
+ public static String getFieldName(String channelId) {
+ return channelId.replace("-", "_");
+ }
+
+ // @formatter:off
+
+ // inverter state
+ public @Nullable @SerializedName(value = "pvstatus") Integer system_status;
+
+ // solar AC and DC generation
+ public @Nullable @SerializedName(value = "pvpowerin") Integer pv_power; // from DC solar
+ public @Nullable @SerializedName(value = "pvpowerout") Integer inverter_power; // to AC mains
+
+ // DC electric data for strings #1 and #2
+ public @Nullable @SerializedName(value = "pv1voltage", alternate = { "vpv1" }) Integer pv1_voltage;
+ public @Nullable @SerializedName(value = "pv1current", alternate = { "buck1curr" }) Integer pv1_current;
+ public @Nullable @SerializedName(value = "pv1watt", alternate = { "ppv1" }) Integer pv1_power;
+
+ public @Nullable @SerializedName(value = "pv2voltage", alternate = { "vpv2" }) Integer pv2_voltage;
+ public @Nullable @SerializedName(value = "pv2current", alternate = { "buck2curr" }) Integer pv2_current;
+ public @Nullable @SerializedName(value = "pv2watt", alternate = { "ppv2" }) Integer pv2_power;
+
+ // AC mains electric data (1-phase resp. 3-phase)
+ public @Nullable @SerializedName(value = "pvfrequentie", alternate = { "line_freq", "outputfreq" }) Integer grid_frequency;
+ public @Nullable @SerializedName(value = "pvgridvoltage", alternate = { "grid_volt", "outputvolt", "voltage_l1" }) Integer grid_voltage_r;
+ public @Nullable @SerializedName(value = "pvgridvoltage2", alternate = { "voltage_l2" }) Integer grid_voltage_s;
+ public @Nullable @SerializedName(value = "pvgridvoltage3", alternate = { "voltage_l3" }) Integer grid_voltage_t;
+ public @Nullable @SerializedName(value = "Vac_RS", alternate = { "vacrs", "L1-2_voltage" }) Integer grid_voltage_rs;
+ public @Nullable @SerializedName(value = "Vac_ST", alternate = { "vacst", "L2-3_voltage" }) Integer grid_voltage_st;
+ public @Nullable @SerializedName(value = "Vac_TR", alternate = { "vactr", "L3-1_voltage" }) Integer grid_voltage_tr;
+
+ // solar AC mains power
+ public @Nullable @SerializedName(value = "pvgridcurrent", alternate = { "OP_Curr", "Inv_Curr", "Current_l1" }) Integer inverter_current_r;
+ public @Nullable @SerializedName(value = "pvgridcurrent2", alternate = { "Current_l2" }) Integer inverter_current_s;
+ public @Nullable @SerializedName(value = "pvgridcurrent3", alternate = { "Current_l3" }) Integer inverter_current_t;
+
+ public @Nullable @SerializedName(value = "pvgridpower", alternate = { "op_watt", "AC_InWatt" }) Integer inverter_power_r;
+ public @Nullable @SerializedName(value = "pvgridpower2") Integer inverter_power_s;
+ public @Nullable @SerializedName(value = "pvgridpower3") Integer inverter_power_t;
+
+ // apparent power VA
+ public @Nullable @SerializedName(value = "op_va", alternate = { "AC_InVA" }) Integer inverter_va;
+
+ // battery discharge / charge power
+ public @Nullable @SerializedName(value = "p1charge1", alternate = { "acchr_watt", "BatWatt", "bdc1_pchr" }) Integer charge_power;
+ public @Nullable @SerializedName(value = "pdischarge1", alternate = { "ACDischarWatt", "BatDischarWatt", "bdc1_pdischr" }) Integer discharge_power;
+
+ // miscellaneous battery
+ public @Nullable @SerializedName(value = "ACCharCurr") Integer charge_current;
+ public @Nullable @SerializedName(value = "ACDischarVA", alternate = { "BatDischarVA", "acchar_VA" }) Integer discharge_va;
+
+ // power exported to utility company
+ public @Nullable @SerializedName(value = "pactogridtot", alternate = { "ptogridtotal" }) Integer export_power;
+ public @Nullable @SerializedName(value = "pactogridr") Integer export_power_r;
+ public @Nullable @SerializedName(value = "pactogrids") Integer export_power_s;
+ public @Nullable @SerializedName(value = "pactogridt") Integer export_power_t;
+
+ // power imported from utility company
+ public @Nullable @SerializedName(value = "pactousertot", alternate = { "ptousertotal", "pos_rev_act_power" }) Integer import_power;
+ public @Nullable @SerializedName(value = "pactouserr", alternate = { "act_power_l1" }) Integer import_power_r;
+ public @Nullable @SerializedName(value = "pactousers", alternate = { "act_power_l2" }) Integer import_power_s;
+ public @Nullable @SerializedName(value = "pactousert", alternate = { "act_power_l3" }) Integer import_power_t;
+
+ // power delivered to internal load
+ public @Nullable @SerializedName(value = "plocaloadtot", alternate = { "ptoloadtotal" }) Integer load_power;
+ public @Nullable @SerializedName(value = "plocaloadr") Integer load_power_r;
+ public @Nullable @SerializedName(value = "plocaloads") Integer load_power_s;
+ public @Nullable @SerializedName(value = "plocaloadt") Integer load_power_t;
+
+ // inverter AC energy
+ public @Nullable @SerializedName(value = "eactoday", alternate = { "pvenergytoday" }) Integer inverter_energy_today;
+ public @Nullable @SerializedName(value = "eactotal", alternate = { "pvenergytotal" }) Integer inverter_energy_total;
+
+ // solar DC pv energy
+ public @Nullable @SerializedName(value = "epvtoday") Integer pv_energy_today;
+ public @Nullable @SerializedName(value = "epv1today", alternate = { "epv1tod" }) Integer pv1_energy_today;
+ public @Nullable @SerializedName(value = "epv2today", alternate = { "epv2tod" }) Integer pv2_energy_today;
+
+ public @Nullable @SerializedName(value = "epvtotal") Integer pv_energy_total;
+ public @Nullable @SerializedName(value = "epv1total", alternate = { "epv1tot" }) Integer pv1_energy_total;
+ public @Nullable @SerializedName(value = "epv2total", alternate = { "epv2tot" }) Integer pv2_energy_total;
+
+ // energy exported to utility company
+ public @Nullable @SerializedName(value = "etogrid_tod", alternate = { "etogridtoday" }) Integer export_energy_today;
+ public @Nullable @SerializedName(value = "etogrid_tot", alternate = { "etogridtotal", "rev_act_energy" }) Integer export_energy_total;
+
+ // energy imported from utility company
+ public @Nullable @SerializedName(value = "etouser_tod", alternate = { "etousertoday" }) Integer import_energy_today;
+ public @Nullable @SerializedName(value = "etouser_tot", alternate = { "etousertotal", "pos_act_energy" }) Integer import_energy_total;
+
+ // energy supplied to local load
+ public @Nullable @SerializedName(value = "elocalload_tod", alternate = { "eloadtoday" }) Integer load_energy_today;
+ public @Nullable @SerializedName(value = "elocalload_tot", alternate = { "eloadtotal" }) Integer load_energy_total;
+
+ // charging energy from import
+ public @Nullable @SerializedName(value = "eacharge_today", alternate = { "eacCharToday", "eacchrtoday" }) Integer import_charge_energy_today;
+ public @Nullable @SerializedName(value = "eacharge_total", alternate = { "eacCharTotal", "eacchrtotal" }) Integer import_charge_energy_total;
+
+ // charging energy from solar
+ public @Nullable @SerializedName(value = "eharge1_tod", alternate = { "echrtoday" }) Integer inverter_charge_energy_today;
+ public @Nullable @SerializedName(value = "eharge1_tot", alternate = { "echrtotal" }) Integer inverter_charge_energy_total;
+
+ // discharging energy
+ public @Nullable @SerializedName(value = "edischarge1_tod", alternate = { "eacDischarToday", "ebatDischarToday", "edischrtoday" }) Integer discharge_energy_today;
+ public @Nullable @SerializedName(value = "edischarge1_tot", alternate = { "eacDischarTotal", "ebatDischarTotal", "edischrtotal" }) Integer discharge_energy_total;
+
+ // inverter up time
+ public @Nullable @SerializedName(value = "totworktime") Integer total_work_time;
+
+ // bus voltages
+ public @Nullable @SerializedName(value = "pbusvolt", alternate = { "bus_volt", "pbusvoltage" }) Integer p_bus_voltage;
+ public @Nullable @SerializedName(value = "nbusvolt", alternate = { "nbusvoltage" }) Integer n_bus_voltage;
+ public @Nullable @SerializedName(value = "spbusvolt") Integer sp_bus_voltage;
+
+ // temperatures
+ public @Nullable @SerializedName(value = "pvtemperature", alternate = { "dcdctemp", "buck1_ntc" }) Integer pv_temperature;
+ public @Nullable @SerializedName(value = "pvipmtemperature", alternate = { "invtemp" }) Integer pv_ipm_temperature;
+ public @Nullable @SerializedName(value = "pvboosttemp", alternate = { "pvboottemperature", "temp3" }) Integer pv_boost_temperature;
+ public @Nullable @SerializedName(value = "temp4") Integer temperature_4;
+ public @Nullable @SerializedName(value = "buck2_ntc", alternate = { "temp5" }) Integer pv2_temperature;
+
+ // battery data
+ public @Nullable @SerializedName(value = "batterytype") Integer battery_type;
+ public @Nullable @SerializedName(value = "batttemp", alternate = { "bdc1_tempa" }) Integer battery_temperature;
+ public @Nullable @SerializedName(value = "vbat", alternate = { "uwBatVolt_DSP", "bat_Volt", "bms_batteryvolt" }) Integer battery_voltage;
+ public @Nullable @SerializedName(value = "bat_dsp") Integer battery_display;
+ public @Nullable @SerializedName(value = "SOC", alternate = { "batterySOC", "bms_soc" }) Integer battery_soc;
+
+ // fault codes
+ public @Nullable @SerializedName(value = "systemfaultword0", alternate = { "isof", "faultBit" }) Integer system_fault_0;
+ public @Nullable @SerializedName(value = "systemfaultword1", alternate = { "gfcif", "faultValue" }) Integer system_fault_1;
+ public @Nullable @SerializedName(value = "systemfaultword2", alternate = { "dcif", "warningBit" }) Integer system_fault_2;
+ public @Nullable @SerializedName(value = "systemfaultword3", alternate = { "vpvfault", "warningValue" }) Integer system_fault_3;
+ public @Nullable @SerializedName(value = "systemfaultword4", alternate = { "vacfault" }) Integer system_fault_4;
+ public @Nullable @SerializedName(value = "systemfaultword5", alternate = { "facfault" }) Integer system_fault_5;
+ public @Nullable @SerializedName(value = "systemfaultword6", alternate = { "tempfault" }) Integer system_fault_6;
+ public @Nullable @SerializedName(value = "systemfaultword7", alternate = { "faultcode" }) Integer system_fault_7;
+
+ // miscellaneous
+ public @Nullable @SerializedName(value = "uwsysworkmode") Integer system_work_mode;
+ public @Nullable @SerializedName(value = "spdspstatus") Integer sp_display_status;
+ public @Nullable @SerializedName(value = "constantPowerOK") Integer constant_power_ok;
+ public @Nullable @SerializedName(value = "loadpercent") Integer load_percent;
+
+ // reactive 'power' resp. 'energy'
+ public @Nullable @SerializedName(value = "rac", alternate = { "react_power" }) Integer rac;
+ public @Nullable @SerializedName(value = "eractoday", alternate = { "react_energy_kvar" }) Integer erac_today;
+ public @Nullable @SerializedName(value = "eractotal") Integer erac_total;
+
+ // @formatter:on
+}
diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrowattDevice.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrowattDevice.java
new file mode 100644
index 00000000000..bfabe18412f
--- /dev/null
+++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrowattDevice.java
@@ -0,0 +1,38 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.growatt.internal.dto;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * The {@link GrowattDevice} is a DTO containing device data fields received from the Growatt cloud server.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ */
+@NonNullByDefault
+public class GrowattDevice {
+
+ private @Nullable String deviceType;
+ private @Nullable String deviceSn;
+
+ public String getId() {
+ String deviceSn = this.deviceSn;
+ return deviceSn != null ? deviceSn : "";
+ }
+
+ public String getType() {
+ String deviceType = this.deviceType;
+ return deviceType != null ? deviceType : "";
+ }
+}
diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrowattPlant.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrowattPlant.java
new file mode 100644
index 00000000000..d66cb696580
--- /dev/null
+++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrowattPlant.java
@@ -0,0 +1,38 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.growatt.internal.dto;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * The {@link GrowattPlant} is a DTO containing plant data fields received from the Growatt cloud server.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ */
+@NonNullByDefault
+public class GrowattPlant {
+
+ private @Nullable String plantId;
+ private @Nullable String plantName;
+
+ public String getId() {
+ String plantId = this.plantId;
+ return plantId != null ? plantId : "";
+ }
+
+ public String getName() {
+ String plantName = this.plantName;
+ return plantName != null ? plantName : "";
+ }
+}
diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrowattPlantList.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrowattPlantList.java
new file mode 100644
index 00000000000..ac6f61cea6b
--- /dev/null
+++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrowattPlantList.java
@@ -0,0 +1,46 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.growatt.internal.dto;
+
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * The {@link GrowattPlantList} is a DTO containing plant list and user data fields received from the Growatt cloud
+ * server.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ */
+@NonNullByDefault
+public class GrowattPlantList {
+
+ private @Nullable List data;
+ private @Nullable GrowattUser user;
+ private @Nullable Boolean success;
+
+ public List getPlants() {
+ List data = this.data;
+ return data != null ? data : List.of();
+ }
+
+ public Boolean getSuccess() {
+ Boolean success = this.success;
+ return success != null ? success : false;
+ }
+
+ public @Nullable GrowattUser getUserId() {
+ return user;
+ }
+}
diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrowattUser.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrowattUser.java
new file mode 100644
index 00000000000..e3a9dbacc52
--- /dev/null
+++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrowattUser.java
@@ -0,0 +1,32 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.growatt.internal.dto;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * The {@link GrowattUser} is a DTO containing user data fields received from the Growatt cloud server.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ */
+@NonNullByDefault
+public class GrowattUser {
+
+ private @Nullable String id;
+
+ public String getId() {
+ String id = this.id;
+ return id != null ? id : "";
+ }
+}
diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/helper/GrottIntegerDeserializer.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/helper/GrottIntegerDeserializer.java
new file mode 100644
index 00000000000..a105c515ac5
--- /dev/null
+++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/helper/GrottIntegerDeserializer.java
@@ -0,0 +1,48 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.growatt.internal.dto.helper;
+
+import java.lang.reflect.Type;
+import java.util.Objects;
+
+import org.eclipse.jdt.annotation.NonNull;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+import com.google.gson.JsonDeserializationContext;
+import com.google.gson.JsonDeserializer;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonParseException;
+
+/**
+ * Special deserializer for integer values. It processes inputs which overflow the Integer.MAX_VALUE limit by
+ * transposing them to negative numbers by means of the 2's complement process.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ */
+@NonNullByDefault
+public class GrottIntegerDeserializer implements JsonDeserializer {
+
+ private static final long INT_BIT_MASK = 0xffffffff;
+
+ @Override
+ public @NonNull Integer deserialize(@Nullable JsonElement json, @Nullable Type typeOfT,
+ @Nullable JsonDeserializationContext context) throws JsonParseException {
+ long value = Long.parseLong(Objects.requireNonNull(json).getAsString());
+ if (value > Integer.MAX_VALUE) {
+ // transpose values above Integer.MAX_VALUE to a negative int by 2's complement
+ return Integer.valueOf(1 - (int) (value ^ INT_BIT_MASK));
+ }
+ return Long.valueOf(value).intValue();
+ }
+}
diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/helper/GrottValuesHelper.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/helper/GrottValuesHelper.java
new file mode 100644
index 00000000000..4b33af11671
--- /dev/null
+++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/helper/GrottValuesHelper.java
@@ -0,0 +1,57 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.growatt.internal.dto.helper;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.growatt.internal.GrowattChannels;
+import org.openhab.binding.growatt.internal.GrowattChannels.UoM;
+import org.openhab.binding.growatt.internal.dto.GrottValues;
+import org.openhab.core.library.types.QuantityType;
+
+/**
+ * Helper routines for the {@link GrottValues} DTO class.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ */
+@NonNullByDefault
+public class GrottValuesHelper {
+
+ /**
+ * Return the valid values from the given target DTO in a map between channel id and respective QuantityType states.
+ *
+ * @return a map of channel ids and respective QuantityType state values.
+ */
+ public static Map> getChannelStates(GrottValues target)
+ throws NoSuchFieldException, SecurityException, IllegalAccessException, IllegalArgumentException {
+ Map> map = new HashMap<>();
+ GrowattChannels.getMap().entrySet().forEach(entry -> {
+ String channelId = entry.getKey();
+ try {
+ Object field = target.getClass().getField(GrottValues.getFieldName(channelId)).get(target);
+ if (field instanceof Integer) {
+ UoM uom = entry.getValue();
+ map.put(channelId, QuantityType.valueOf(((Integer) field).doubleValue() / uom.divisor, uom.units));
+ }
+ } catch (NoSuchFieldException | SecurityException | IllegalAccessException | IllegalArgumentException e) {
+ // Exceptions should never actually occur at run time; nevertheless the caller logs if one would occur..
+ // - NoSuchFieldException never occurs since we have explicitly tested this in the JUnit tests.
+ // - SecurityException, IllegalAccessException never occur since all fields are public.
+ // - IllegalArgumentException never occurs since we are explicitly working within this same class.
+ }
+ });
+ return map;
+ }
+}
diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/factory/GrowattHandlerFactory.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/factory/GrowattHandlerFactory.java
new file mode 100644
index 00000000000..402cb9c86c3
--- /dev/null
+++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/factory/GrowattHandlerFactory.java
@@ -0,0 +1,153 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.growatt.internal.factory;
+
+import static org.openhab.binding.growatt.internal.GrowattBindingConstants.*;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Hashtable;
+import java.util.Objects;
+import java.util.Set;
+
+import javax.servlet.ServletException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.growatt.internal.discovery.GrowattDiscoveryService;
+import org.openhab.binding.growatt.internal.handler.GrowattBridgeHandler;
+import org.openhab.binding.growatt.internal.handler.GrowattInverterHandler;
+import org.openhab.binding.growatt.internal.servlet.GrowattHttpServlet;
+import org.openhab.core.config.discovery.DiscoveryService;
+import org.openhab.core.i18n.LocaleProvider;
+import org.openhab.core.i18n.TranslationProvider;
+import org.openhab.core.io.net.http.HttpClientFactory;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.ThingUID;
+import org.openhab.core.thing.binding.BaseThingHandlerFactory;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerFactory;
+import org.osgi.framework.ServiceRegistration;
+import org.osgi.service.component.ComponentContext;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+import org.osgi.service.http.HttpService;
+import org.osgi.service.http.NamespaceException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link GrowattHandlerFactory} is responsible for creating things and thing
+ * handlers.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ */
+@NonNullByDefault
+@Component(configurationPid = "binding.growatt", service = ThingHandlerFactory.class)
+public class GrowattHandlerFactory extends BaseThingHandlerFactory {
+
+ private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_BRIDGE, THING_TYPE_INVERTER);
+
+ private final Logger logger = LoggerFactory.getLogger(GrowattHandlerFactory.class);
+
+ private final HttpService httpService;
+ private final HttpClientFactory httpClientFactory;
+ private final TranslationProvider i18nProvider;
+ private final LocaleProvider localeProvider;
+ private final Set bridges = Collections.synchronizedSet(new HashSet<>());
+ private final GrowattHttpServlet httpServlet = new GrowattHttpServlet();
+
+ private @Nullable GrowattDiscoveryService discoveryService;
+ private @Nullable ServiceRegistration> discoveryServiceRegistration;
+
+ @Activate
+ public GrowattHandlerFactory(@Reference HttpService httpService, @Reference HttpClientFactory httpClientFactory,
+ @Reference TranslationProvider i18nProvider, @Reference LocaleProvider localeProvider) {
+ this.httpService = httpService;
+ this.httpClientFactory = httpClientFactory;
+ this.i18nProvider = i18nProvider;
+ this.localeProvider = localeProvider;
+ try {
+ httpService.registerServlet(GrowattHttpServlet.PATH, httpServlet, null, null);
+ } catch (ServletException | NamespaceException e) {
+ logger.warn("GrowattHandlerFactory() failed to register servlet", e);
+ }
+ }
+
+ @Override
+ protected @Nullable ThingHandler createHandler(Thing thing) {
+ ThingTypeUID thingTypeUID = thing.getThingTypeUID();
+
+ if (THING_TYPE_BRIDGE.equals(thingTypeUID)) {
+ discoveryRegister();
+ bridges.add(thing.getUID());
+ return new GrowattBridgeHandler((Bridge) thing, Objects.requireNonNull(httpServlet),
+ Objects.requireNonNull(discoveryService), httpClientFactory);
+ }
+
+ if (THING_TYPE_INVERTER.equals(thingTypeUID)) {
+ return new GrowattInverterHandler(thing);
+ }
+
+ return null;
+ }
+
+ @Override
+ protected void deactivate(ComponentContext componentContext) {
+ bridges.clear();
+ discoveryUnregister();
+ httpService.unregister(GrowattHttpServlet.PATH);
+ super.deactivate(componentContext);
+ }
+
+ private void discoveryRegister() {
+ GrowattDiscoveryService discoveryService = this.discoveryService;
+ if (discoveryService == null) {
+ discoveryService = new GrowattDiscoveryService(i18nProvider, localeProvider);
+ this.discoveryService = discoveryService;
+ }
+ ServiceRegistration> temp = this.discoveryServiceRegistration;
+ if (temp == null) {
+ temp = bundleContext.registerService(DiscoveryService.class.getName(), discoveryService, new Hashtable<>());
+ this.discoveryServiceRegistration = temp;
+ }
+ }
+
+ private void discoveryUnregister() {
+ ServiceRegistration> discoveryServiceRegistration = this.discoveryServiceRegistration;
+ if (discoveryServiceRegistration != null) {
+ discoveryServiceRegistration.unregister();
+ }
+ this.discoveryService = null;
+ this.discoveryServiceRegistration = null;
+ }
+
+ @Override
+ protected void removeHandler(ThingHandler thingHandler) {
+ if (thingHandler instanceof GrowattBridgeHandler) {
+ bridges.remove(thingHandler.getThing().getUID());
+ if (bridges.isEmpty()) {
+ discoveryUnregister();
+ }
+ }
+ super.removeHandler(thingHandler);
+ }
+
+ @Override
+ public boolean supportsThingType(ThingTypeUID thingTypeUID) {
+ return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
+ }
+}
diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/handler/GrowattBridgeHandler.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/handler/GrowattBridgeHandler.java
new file mode 100644
index 00000000000..e10dc5e006c
--- /dev/null
+++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/handler/GrowattBridgeHandler.java
@@ -0,0 +1,143 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.growatt.internal.handler;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.growatt.internal.cloud.GrowattCloud;
+import org.openhab.binding.growatt.internal.config.GrowattBridgeConfiguration;
+import org.openhab.binding.growatt.internal.discovery.GrowattDiscoveryService;
+import org.openhab.binding.growatt.internal.dto.GrottDevice;
+import org.openhab.binding.growatt.internal.dto.helper.GrottIntegerDeserializer;
+import org.openhab.binding.growatt.internal.servlet.GrowattHttpServlet;
+import org.openhab.core.io.net.http.HttpClientFactory;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.binding.BaseBridgeHandler;
+import org.openhab.core.types.Command;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonParser;
+import com.google.gson.JsonSyntaxException;
+
+/**
+ * The {@link GrowattBridgeHandler} is a bridge handler for accessing Growatt inverters via the Grott application.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ */
+@NonNullByDefault
+public class GrowattBridgeHandler extends BaseBridgeHandler {
+
+ private final Logger logger = LoggerFactory.getLogger(GrowattBridgeHandler.class);
+ private final Gson gson = new GsonBuilder().registerTypeAdapter(Integer.class, new GrottIntegerDeserializer())
+ .create();
+ private final GrowattDiscoveryService discoveryService;
+ private final Map inverters = new HashMap<>();
+ private final GrowattHttpServlet httpServlet;
+ private final HttpClientFactory httpClientFactory;
+
+ private @Nullable GrowattCloud growattCloud;
+
+ public GrowattBridgeHandler(Bridge bridge, GrowattHttpServlet httpServlet, GrowattDiscoveryService discoveryService,
+ HttpClientFactory httpClientFactory) {
+ super(bridge);
+ this.httpServlet = httpServlet;
+ this.discoveryService = discoveryService;
+ this.httpClientFactory = httpClientFactory;
+ }
+
+ @Override
+ public void dispose() {
+ inverters.clear();
+ httpServlet.handlerRemove(this);
+ discoveryService.putInverters(thing.getUID(), inverters.keySet());
+ }
+
+ public GrowattCloud getGrowattCloud() throws IllegalStateException {
+ GrowattCloud growattCloud = this.growattCloud;
+ if (growattCloud == null) {
+ try {
+ growattCloud = new GrowattCloud(getConfigAs(GrowattBridgeConfiguration.class), httpClientFactory);
+ } catch (Exception e) {
+ throw new IllegalStateException("GrowattCloud not created", e);
+ }
+ this.growattCloud = growattCloud;
+ }
+ return growattCloud;
+ }
+
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ // everything is read only so do nothing
+ }
+
+ /**
+ * Process JSON content posted to the Grott application servlet.
+ */
+ @SuppressWarnings("null")
+ public void handleGrottContent(String json) {
+ logger.trace("handleGrottContent() json:{}", json);
+ JsonElement jsonElement;
+ try {
+ jsonElement = JsonParser.parseString(json);
+ if (jsonElement.isJsonPrimitive()) {
+ // strip double escaping from Grott JSON
+ jsonElement = JsonParser.parseString(jsonElement.getAsString());
+ }
+ if (!jsonElement.isJsonObject()) {
+ throw new JsonSyntaxException("Unsupported JSON element type");
+ }
+ } catch (JsonSyntaxException e) {
+ logger.debug("handleGrottContent() invalid JSON '{}'", json, e);
+ return;
+ }
+ try {
+ GrottDevice inverter = gson.fromJson(jsonElement, GrottDevice.class);
+ if (inverter == null) {
+ throw new JsonSyntaxException("Inverter object is null");
+ }
+ putInverter(inverter);
+ } catch (JsonSyntaxException e) {
+ logger.debug("handleGrottContent() error parsing JSON '{}'", json, e);
+ return;
+ }
+ getThing().getThings().stream().map(thing -> thing.getHandler())
+ .filter(handler -> (handler instanceof GrowattInverterHandler))
+ .forEach(handler -> ((GrowattInverterHandler) handler).updateInverters(inverters.values()));
+ }
+
+ @Override
+ public void initialize() {
+ httpServlet.handlerAdd(this);
+ updateStatus(ThingStatus.ONLINE);
+ }
+
+ /**
+ * Put the given GrottDevice in our inverters map, and notify the discovery service if it was not already there.
+ *
+ * @param inverter a GrottDevice inverter object.
+ */
+ private void putInverter(GrottDevice inverter) {
+ if (inverters.put(inverter.getDeviceId(), inverter) == null) {
+ discoveryService.putInverters(thing.getUID(), inverters.keySet());
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/handler/GrowattInverterHandler.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/handler/GrowattInverterHandler.java
new file mode 100644
index 00000000000..eeb960461f5
--- /dev/null
+++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/handler/GrowattInverterHandler.java
@@ -0,0 +1,194 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.growatt.internal.handler;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.growatt.internal.action.GrowattActions;
+import org.openhab.binding.growatt.internal.cloud.GrowattApiException;
+import org.openhab.binding.growatt.internal.cloud.GrowattCloud;
+import org.openhab.binding.growatt.internal.config.GrowattInverterConfiguration;
+import org.openhab.binding.growatt.internal.dto.GrottDevice;
+import org.openhab.binding.growatt.internal.dto.GrottValues;
+import org.openhab.binding.growatt.internal.dto.helper.GrottValuesHelper;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.Channel;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.binding.BaseThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerService;
+import org.openhab.core.types.Command;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link GrowattInverterHandler} is a thing handler for Growatt inverters.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ */
+@NonNullByDefault
+public class GrowattInverterHandler extends BaseThingHandler {
+
+ // data-logger sends packets each 5 minutes; timeout means 2 packets missed
+ private static final int AWAITING_DATA_TIMEOUT_MINUTES = 11;
+
+ private final Logger logger = LoggerFactory.getLogger(GrowattInverterHandler.class);
+
+ private String deviceId = "unknown";
+
+ private @Nullable ScheduledFuture> awaitingDataTimeoutTask;
+
+ public GrowattInverterHandler(Thing thing) {
+ super(thing);
+ }
+
+ @Override
+ public void dispose() {
+ ScheduledFuture> task = awaitingDataTimeoutTask;
+ if (task != null) {
+ task.cancel(true);
+ }
+ }
+
+ @Override
+ public Collection> getServices() {
+ return List.of(GrowattActions.class);
+ }
+
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ // everything is read only so do nothing
+ }
+
+ @Override
+ public void initialize() {
+ GrowattInverterConfiguration config = getConfigAs(GrowattInverterConfiguration.class);
+ deviceId = config.deviceId;
+ thing.setProperty(GrowattInverterConfiguration.DEVICE_ID, deviceId);
+ updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.NONE, "@text/status.awaiting-data");
+ scheduleAwaitingDataTimeoutTask();
+ logger.debug("initialize() thing has {} channels", thing.getChannels().size());
+ }
+
+ private void scheduleAwaitingDataTimeoutTask() {
+ ScheduledFuture> task = awaitingDataTimeoutTask;
+ if (task != null) {
+ task.cancel(true);
+ }
+ awaitingDataTimeoutTask = scheduler.schedule(() -> {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+ "@text/status.awaiting-data-timeout");
+ }, AWAITING_DATA_TIMEOUT_MINUTES, TimeUnit.MINUTES);
+ }
+
+ /**
+ * Receives a collection of GrottDevice inverter objects containing potential data for this thing. If the collection
+ * contains an entry matching the things's deviceId, and it contains GrottValues, then process it further. Otherwise
+ * go offline with a configuration error.
+ *
+ * @param inverters collection of GrottDevice objects.
+ */
+ public void updateInverters(Collection inverters) {
+ inverters.stream().filter(inverter -> deviceId.equals(inverter.getDeviceId()))
+ .map(inverter -> inverter.getValues()).filter(values -> values != null).findAny()
+ .ifPresentOrElse(values -> {
+ updateStatus(ThingStatus.ONLINE);
+ scheduleAwaitingDataTimeoutTask();
+ updateInverterValues(values);
+ }, () -> {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR);
+ });
+ }
+
+ /**
+ * Receives a GrottValues object containing state values for this thing. Process the respective values and update
+ * the channels accordingly.
+ *
+ * @param inverter a GrottDevice object containing the new status values.
+ */
+ public void updateInverterValues(GrottValues inverterValues) {
+ // get channel states
+ Map> channelStates;
+ try {
+ channelStates = GrottValuesHelper.getChannelStates(inverterValues);
+ } catch (NoSuchFieldException | SecurityException | IllegalAccessException | IllegalArgumentException e) {
+ logger.warn("updateInverterValues() unexpected exception:{}, message:{}", e.getClass().getName(),
+ e.getMessage(), e);
+ return;
+ }
+
+ // find unused channels
+ List actualChannels = thing.getChannels();
+ List unusedChannels = actualChannels.stream()
+ .filter(channel -> !channelStates.containsKey(channel.getUID().getId())).collect(Collectors.toList());
+
+ // remove unused channels
+ if (!unusedChannels.isEmpty()) {
+ updateThing(editThing().withoutChannels(unusedChannels).build());
+ logger.debug("updateInverterValues() channel count {} reduced by {} to {}", actualChannels.size(),
+ unusedChannels.size(), thing.getChannels().size());
+ }
+
+ List thingChannelIds = thing.getChannels().stream().map(channel -> channel.getUID().getId())
+ .collect(Collectors.toList());
+
+ // update channel states
+ channelStates.forEach((channelId, state) -> {
+ if (thingChannelIds.contains(channelId)) {
+ updateState(channelId, state);
+ } else {
+ logger.warn("updateInverterValues() channel '{}' not found; try re-creating the thing", channelId);
+ }
+ });
+ }
+
+ private GrowattCloud getGrowattCloud() throws IllegalStateException {
+ Bridge bridge = getBridge();
+ if (bridge != null && (bridge.getHandler() instanceof GrowattBridgeHandler bridgeHandler)) {
+ return bridgeHandler.getGrowattCloud();
+ }
+ throw new IllegalStateException("Unable to get GrowattCloud from bridge handler");
+ }
+
+ /**
+ * This method is called from a Rule Action to setup the battery charging program.
+ *
+ * @param programMode indicates if the program is Load first (0), Battery first (1), Grid first (2)
+ * @param powerLevel the rate of charging / discharging 0%..100%
+ * @param stopSOC the SOC at which to stop charging / discharging 0%..100%
+ * @param enableAcCharging allow the battery to be charged from AC power
+ * @param startTime the start time of the charging program; a time formatted string e.g. "12:34"
+ * @param stopTime the stop time of the charging program; a time formatted string e.g. "12:34"
+ * @param enableProgram charge / discharge program shall be enabled
+ */
+ public void setupBatteryProgram(Integer programMode, @Nullable Integer powerLevel, @Nullable Integer stopSOC,
+ @Nullable Boolean enableAcCharging, @Nullable String startTime, @Nullable String stopTime,
+ @Nullable Boolean enableProgram) {
+ try {
+ getGrowattCloud().setupBatteryProgram(deviceId, programMode, powerLevel, stopSOC, enableAcCharging,
+ startTime, stopTime, enableProgram);
+ } catch (GrowattApiException e) {
+ logger.warn("setupBatteryProgram() error", e);
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/servlet/GrowattHttpServlet.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/servlet/GrowattHttpServlet.java
new file mode 100644
index 00000000000..831fc3bb537
--- /dev/null
+++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/servlet/GrowattHttpServlet.java
@@ -0,0 +1,86 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.growatt.internal.servlet;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.ws.rs.core.MediaType;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.growatt.internal.handler.GrowattBridgeHandler;
+
+/**
+ * The {@link GrowattHttpServlet} is an HttpServlet to handle data posted by the Grott application.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ */
+@NonNullByDefault
+public class GrowattHttpServlet extends HttpServlet {
+
+ public static final String PATH = "/growatt";
+
+ private static final String HTML = ""
+ // @formatter:off
+ + ""
+ + ""
+ + "
Growatt Binding Servlet
"
+ + "
"
+ + "
Status: %s
"
+ + ""
+ + "";
+ // @formatter:on
+
+ private static final String COLOR_READY = "ff6600";
+ private static final String COLOR_ONLINE = "339966";
+ private static final String MESSAGE_READY = "Ready";
+ private static final String MESSAGE_ONLINE = "Bridge Online";
+
+ private static final long serialVersionUID = 36178542423191036L;
+
+ private final Set handlers = Collections.synchronizedSet(new HashSet<>());
+
+ @Override
+ protected void doGet(HttpServletRequest req, HttpServletResponse response) throws ServletException, IOException {
+ response.setStatus(HttpServletResponse.SC_OK);
+ response.setContentType(MediaType.TEXT_HTML);
+ response.getWriter().write(String.format(HTML, handlers.isEmpty() ? COLOR_READY : COLOR_ONLINE,
+ handlers.isEmpty() ? COLOR_READY : MESSAGE_ONLINE));
+ }
+
+ @Override
+ protected void doPost(HttpServletRequest request, HttpServletResponse response)
+ throws ServletException, IOException {
+ response.setStatus(HttpServletResponse.SC_OK);
+ response.getWriter().write(handlers.isEmpty() ? MESSAGE_READY : MESSAGE_ONLINE);
+ if (request.getContentLength() > 0) {
+ String content = new String(request.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
+ handlers.forEach(handler -> handler.handleGrottContent(content));
+ }
+ }
+
+ public void handlerAdd(GrowattBridgeHandler handler) {
+ handlers.add(handler);
+ }
+
+ public void handlerRemove(GrowattBridgeHandler handler) {
+ handlers.remove(handler);
+ }
+}
diff --git a/bundles/org.openhab.binding.growatt/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.binding.growatt/src/main/resources/OH-INF/addon/addon.xml
new file mode 100644
index 00000000000..2c52b5e92ac
--- /dev/null
+++ b/bundles/org.openhab.binding.growatt/src/main/resources/OH-INF/addon/addon.xml
@@ -0,0 +1,11 @@
+
+
+
+ binding
+ Growatt Binding
+ This is the binding for Growatt solar inverters.
+ hybrid
+
+
diff --git a/bundles/org.openhab.binding.growatt/src/main/resources/OH-INF/i18n/growatt.properties b/bundles/org.openhab.binding.growatt/src/main/resources/OH-INF/i18n/growatt.properties
new file mode 100644
index 00000000000..6c11e744716
--- /dev/null
+++ b/bundles/org.openhab.binding.growatt/src/main/resources/OH-INF/i18n/growatt.properties
@@ -0,0 +1,241 @@
+# add-on
+
+addon.growatt.name = Growatt Binding
+addon.growatt.description = This is the binding for Growatt solar inverters.
+
+# thing types
+
+thing-type.growatt.bridge.label = Growatt Bridge
+thing-type.growatt.bridge.description = Bridge Thing for Growatt Binding
+thing-type.growatt.inverter.label = Growatt Inverter
+thing-type.growatt.inverter.description = Inverter Thing for Growatt Binding
+thing-type.growatt.inverter.channel.battery-display.label = Battery Display
+thing-type.growatt.inverter.channel.battery-display.description = Battery display code.
+thing-type.growatt.inverter.channel.battery-soc.label = Battery Charge
+thing-type.growatt.inverter.channel.battery-soc.description = Battery state of charge.
+thing-type.growatt.inverter.channel.battery-temperature.label = Battery Temperature
+thing-type.growatt.inverter.channel.battery-temperature.description = Battery temperature.
+thing-type.growatt.inverter.channel.battery-type.label = Battery Type
+thing-type.growatt.inverter.channel.battery-type.description = Type code of the battery.
+thing-type.growatt.inverter.channel.battery-voltage.label = Battery Voltage
+thing-type.growatt.inverter.channel.battery-voltage.description = Battery voltage.
+thing-type.growatt.inverter.channel.charge-current.label = Charge Current
+thing-type.growatt.inverter.channel.charge-current.description = Charge current to battery.
+thing-type.growatt.inverter.channel.charge-power.label = Charge Power
+thing-type.growatt.inverter.channel.charge-power.description = Charge power to battery.
+thing-type.growatt.inverter.channel.constant-power-ok.label = Constant Power OK
+thing-type.growatt.inverter.channel.constant-power-ok.description = Constant power OK code.
+thing-type.growatt.inverter.channel.discharge-energy-today.label = Battery Energy Today
+thing-type.growatt.inverter.channel.discharge-energy-today.description = Energy consumed from battery today.
+thing-type.growatt.inverter.channel.discharge-energy-total.label = Battery Energy Total
+thing-type.growatt.inverter.channel.discharge-energy-total.description = Total energy consumed from battery.
+thing-type.growatt.inverter.channel.discharge-power.label = Discharge Power
+thing-type.growatt.inverter.channel.discharge-power.description = Discharge power from battery.
+thing-type.growatt.inverter.channel.discharge-va.label = Discharge VA
+thing-type.growatt.inverter.channel.discharge-va.description = Discharge VA from battery.
+thing-type.growatt.inverter.channel.erac-today.label = Reactive Energy Today
+thing-type.growatt.inverter.channel.erac-today.description = Reactive energy supplied today.
+thing-type.growatt.inverter.channel.erac-total.label = Total Reactive Energy
+thing-type.growatt.inverter.channel.erac-total.description = Total reactive energy supplied.
+thing-type.growatt.inverter.channel.export-energy-today.label = Export Energy Today
+thing-type.growatt.inverter.channel.export-energy-today.description = Energy exported to grid today.
+thing-type.growatt.inverter.channel.export-energy-total.label = Export Energy Total
+thing-type.growatt.inverter.channel.export-energy-total.description = Total energy exported to grid.
+thing-type.growatt.inverter.channel.export-power.label = Export Power
+thing-type.growatt.inverter.channel.export-power.description = Power exported to grid.
+thing-type.growatt.inverter.channel.export-power-r.label = Export Power #R
+thing-type.growatt.inverter.channel.export-power-r.description = Power exported to grid phase #R.
+thing-type.growatt.inverter.channel.export-power-s.label = Export Power #S
+thing-type.growatt.inverter.channel.export-power-s.description = Power exported to grid phase #S.
+thing-type.growatt.inverter.channel.export-power-t.label = Export Power #T
+thing-type.growatt.inverter.channel.export-power-t.description = Power exported to grid phase #T.
+thing-type.growatt.inverter.channel.grid-frequency.label = Grid Frequency
+thing-type.growatt.inverter.channel.grid-frequency.description = Frequency of the grid.
+thing-type.growatt.inverter.channel.grid-voltage-r.label = Grid Voltage (#R)
+thing-type.growatt.inverter.channel.grid-voltage-r.description = Voltage of the grid (phase #R).
+thing-type.growatt.inverter.channel.grid-voltage-rs.label = Grid Voltage #RS
+thing-type.growatt.inverter.channel.grid-voltage-rs.description = Voltage of the grid phases #RS.
+thing-type.growatt.inverter.channel.grid-voltage-s.label = Grid Voltage #S
+thing-type.growatt.inverter.channel.grid-voltage-s.description = Voltage of the grid phase #S.
+thing-type.growatt.inverter.channel.grid-voltage-st.label = Grid Voltage #ST
+thing-type.growatt.inverter.channel.grid-voltage-st.description = Voltage of the grid phases #ST.
+thing-type.growatt.inverter.channel.grid-voltage-t.label = Grid Voltage #T
+thing-type.growatt.inverter.channel.grid-voltage-t.description = Voltage of the grid phase #T.
+thing-type.growatt.inverter.channel.grid-voltage-tr.label = Grid Voltage #TR
+thing-type.growatt.inverter.channel.grid-voltage-tr.description = Voltage of the grid phases #TR.
+thing-type.growatt.inverter.channel.import-charge-energy-today.label = Battery Import Energy Today
+thing-type.growatt.inverter.channel.import-charge-energy-today.description = Energy imported from grid to charge battery today.
+thing-type.growatt.inverter.channel.import-charge-energy-total.label = Battery Import Energy Totals
+thing-type.growatt.inverter.channel.import-charge-energy-total.description = Total energy imported from grid to charge battery.
+thing-type.growatt.inverter.channel.import-energy-today.label = Import Energy Today
+thing-type.growatt.inverter.channel.import-energy-today.description = Energy imported from grid today.
+thing-type.growatt.inverter.channel.import-energy-total.label = Import Energy Total
+thing-type.growatt.inverter.channel.import-energy-total.description = Total energy imported from grid.
+thing-type.growatt.inverter.channel.import-power.label = Import Power
+thing-type.growatt.inverter.channel.import-power.description = Power imported.
+thing-type.growatt.inverter.channel.import-power-r.label = Import Power #R
+thing-type.growatt.inverter.channel.import-power-r.description = Power imported phase #R.
+thing-type.growatt.inverter.channel.import-power-s.label = Import Power #S
+thing-type.growatt.inverter.channel.import-power-s.description = Power imported phase #S.
+thing-type.growatt.inverter.channel.import-power-t.label = Import Power #T
+thing-type.growatt.inverter.channel.import-power-t.description = Power imported phase #T.
+thing-type.growatt.inverter.channel.inverter-charge-energy-today.label = Battery Inverter Energy Today
+thing-type.growatt.inverter.channel.inverter-charge-energy-today.description = Energy from inverter to charge battery today.
+thing-type.growatt.inverter.channel.inverter-charge-energy-total.label = Battery Inverter Energy Total
+thing-type.growatt.inverter.channel.inverter-charge-energy-total.description = Total energy from inverter to charge battery.
+thing-type.growatt.inverter.channel.inverter-current-r.label = Inverter Current (#R)
+thing-type.growatt.inverter.channel.inverter-current-r.description = AC current from inverter (phase #R).
+thing-type.growatt.inverter.channel.inverter-current-s.label = Inverter Current #S
+thing-type.growatt.inverter.channel.inverter-current-s.description = AC current from inverter phase #S.
+thing-type.growatt.inverter.channel.inverter-current-t.label = Inverter Current #T
+thing-type.growatt.inverter.channel.inverter-current-t.description = AC current from inverter phase #T.
+thing-type.growatt.inverter.channel.inverter-energy-today.label = Inverter Energy Today
+thing-type.growatt.inverter.channel.inverter-energy-today.description = Inverter output energy produced today.
+thing-type.growatt.inverter.channel.inverter-energy-total.label = Inverter Energy Total
+thing-type.growatt.inverter.channel.inverter-energy-total.description = Total inverter output energy produced.
+thing-type.growatt.inverter.channel.inverter-power.label = Inverter Power
+thing-type.growatt.inverter.channel.inverter-power.description = AC power the inverter (total).
+thing-type.growatt.inverter.channel.inverter-power-r.label = Inverter Power (#R)
+thing-type.growatt.inverter.channel.inverter-power-r.description = AC power from inverter (phase #R).
+thing-type.growatt.inverter.channel.inverter-power-s.label = Inverter Power #S
+thing-type.growatt.inverter.channel.inverter-power-s.description = AC power from inverter phase #S.
+thing-type.growatt.inverter.channel.inverter-power-t.label = Inverter Power #T
+thing-type.growatt.inverter.channel.inverter-power-t.description = AC power from inverter phase #T.
+thing-type.growatt.inverter.channel.inverter-va.label = Inverter VA
+thing-type.growatt.inverter.channel.inverter-va.description = AC VA produced by inverter.
+thing-type.growatt.inverter.channel.load-energy-today.label = Load Energy Today
+thing-type.growatt.inverter.channel.load-energy-today.description = Energy supplied to load today.
+thing-type.growatt.inverter.channel.load-energy-total.label = Load Energy Total
+thing-type.growatt.inverter.channel.load-energy-total.description = Total energy supplied to load.
+thing-type.growatt.inverter.channel.load-percent.label = Load Percent
+thing-type.growatt.inverter.channel.load-percent.description = Percent of full load.
+thing-type.growatt.inverter.channel.load-power.label = Load Power
+thing-type.growatt.inverter.channel.load-power.description = Power supplied to load.
+thing-type.growatt.inverter.channel.load-power-r.label = Load Power #R
+thing-type.growatt.inverter.channel.load-power-r.description = Power supplied to load phase #R.
+thing-type.growatt.inverter.channel.load-power-s.label = Load Power #S
+thing-type.growatt.inverter.channel.load-power-s.description = Power supplied to load phase #S.
+thing-type.growatt.inverter.channel.load-power-t.label = Load Power #T
+thing-type.growatt.inverter.channel.load-power-t.description = Power supplied to load phase #T.
+thing-type.growatt.inverter.channel.n-bus-voltage.label = N Bus Voltage
+thing-type.growatt.inverter.channel.n-bus-voltage.description = N Bus voltage.
+thing-type.growatt.inverter.channel.p-bus-voltage.label = P Bus Voltage
+thing-type.growatt.inverter.channel.p-bus-voltage.description = P Bus voltage.
+thing-type.growatt.inverter.channel.pv-boost-temperature.label = Boost Temperature
+thing-type.growatt.inverter.channel.pv-boost-temperature.description = Boost temperature.
+thing-type.growatt.inverter.channel.pv-energy-today.label = DC Energy Today
+thing-type.growatt.inverter.channel.pv-energy-today.description = Solar DC energy collected.
+thing-type.growatt.inverter.channel.pv-energy-total.label = DC Energy Total
+thing-type.growatt.inverter.channel.pv-energy-total.description = Total solar energy supplied to grid.
+thing-type.growatt.inverter.channel.pv-ipm-temperature.label = Solar IPM Temperature
+thing-type.growatt.inverter.channel.pv-ipm-temperature.description = Temperature of the IPM.
+thing-type.growatt.inverter.channel.pv-power.label = Solar Input Power
+thing-type.growatt.inverter.channel.pv-power.description = Power from solar panels.
+thing-type.growatt.inverter.channel.pv-temperature.label = Solar Panel Temperature
+thing-type.growatt.inverter.channel.pv-temperature.description = Temperature of the solar panels (string #1).
+thing-type.growatt.inverter.channel.pv1-current.label = String #1 Current
+thing-type.growatt.inverter.channel.pv1-current.description = Current from solar panel string #1.
+thing-type.growatt.inverter.channel.pv1-energy-today.label = DC Energy #1 Today
+thing-type.growatt.inverter.channel.pv1-energy-today.description = Solar DC energy collected by string #1 to grid today.
+thing-type.growatt.inverter.channel.pv1-energy-total.label = DC Energy #1 Total
+thing-type.growatt.inverter.channel.pv1-energy-total.description = Total solar DC collected by string #1.
+thing-type.growatt.inverter.channel.pv1-power.label = String #1 Power
+thing-type.growatt.inverter.channel.pv1-power.description = Power from solar panel string #1.
+thing-type.growatt.inverter.channel.pv1-voltage.label = String #1 Voltage
+thing-type.growatt.inverter.channel.pv1-voltage.description = Voltage from solar panel string #1.
+thing-type.growatt.inverter.channel.pv2-current.label = String #2 Current
+thing-type.growatt.inverter.channel.pv2-current.description = Current from solar panel string #2.
+thing-type.growatt.inverter.channel.pv2-energy-today.label = DC Energy #2 Today
+thing-type.growatt.inverter.channel.pv2-energy-today.description = Solar DC energy collected by string #2 to grid today.
+thing-type.growatt.inverter.channel.pv2-energy-total.label = DC Energy #2 Total
+thing-type.growatt.inverter.channel.pv2-energy-total.description = Total solar DC collected by string #2.
+thing-type.growatt.inverter.channel.pv2-power.label = String #2 Power
+thing-type.growatt.inverter.channel.pv2-power.description = Power from solar panel string #2.
+thing-type.growatt.inverter.channel.pv2-temperature.label = Solar Panel Temperature #2
+thing-type.growatt.inverter.channel.pv2-temperature.description = Temperature of the solar panels (string #2).
+thing-type.growatt.inverter.channel.pv2-voltage.label = String #2 Voltage
+thing-type.growatt.inverter.channel.pv2-voltage.description = Voltage from solar panel string #2.
+thing-type.growatt.inverter.channel.rac.label = Reactive Power
+thing-type.growatt.inverter.channel.rac.description = Reactive power output.
+thing-type.growatt.inverter.channel.sp-bus-voltage.label = SP Bus Voltage
+thing-type.growatt.inverter.channel.sp-bus-voltage.description = SP Bus voltage.
+thing-type.growatt.inverter.channel.sp-display-status.label = Solar Panel Display
+thing-type.growatt.inverter.channel.sp-display-status.description = Solar panel display status code.
+thing-type.growatt.inverter.channel.system-fault-0.label = Fault Code #0
+thing-type.growatt.inverter.channel.system-fault-0.description = System fault code #0.
+thing-type.growatt.inverter.channel.system-fault-1.label = Fault Code #1
+thing-type.growatt.inverter.channel.system-fault-1.description = System fault code #1.
+thing-type.growatt.inverter.channel.system-fault-2.label = Fault Code #2
+thing-type.growatt.inverter.channel.system-fault-2.description = System fault code #2.
+thing-type.growatt.inverter.channel.system-fault-3.label = Fault Code #3
+thing-type.growatt.inverter.channel.system-fault-3.description = System fault code #3.
+thing-type.growatt.inverter.channel.system-fault-4.label = Fault Code #4
+thing-type.growatt.inverter.channel.system-fault-4.description = System fault code #4.
+thing-type.growatt.inverter.channel.system-fault-5.label = Fault Code #5
+thing-type.growatt.inverter.channel.system-fault-5.description = System fault code #5.
+thing-type.growatt.inverter.channel.system-fault-6.label = Fault Code #6
+thing-type.growatt.inverter.channel.system-fault-6.description = System fault code #6.
+thing-type.growatt.inverter.channel.system-fault-7.label = Fault Code #7
+thing-type.growatt.inverter.channel.system-fault-7.description = System fault code #7.
+thing-type.growatt.inverter.channel.system-status.label = Inverter Status
+thing-type.growatt.inverter.channel.system-status.description = Status code of the inverter.
+thing-type.growatt.inverter.channel.system-work-mode.label = System Work Mode
+thing-type.growatt.inverter.channel.system-work-mode.description = System work mode code.
+thing-type.growatt.inverter.channel.temperature-4.label = Temperature #4
+thing-type.growatt.inverter.channel.temperature-4.description = Temperature #4.
+thing-type.growatt.inverter.channel.total-work-time.label = Total Working Time
+thing-type.growatt.inverter.channel.total-work-time.description = Total inverter working time.
+
+# thing types config
+
+thing-type.config.growatt.bridge.password.label = Password
+thing-type.config.growatt.bridge.password.description = Password to login to the Shine App.
+thing-type.config.growatt.bridge.userName.label = User Name
+thing-type.config.growatt.bridge.userName.description = User name to login to the Shine App.
+thing-type.config.growatt.inverter.deviceId.label = Device Id
+thing-type.config.growatt.inverter.deviceId.description = Id (serial number) of the inverter.
+
+# channel types
+
+channel-type.growatt.advanced-electric-current.label = Electric Current
+channel-type.growatt.advanced-electric-energy.label = Electric Energy
+channel-type.growatt.advanced-electric-frequency.label = Electric Frequency
+channel-type.growatt.advanced-electric-kvarh.label = Electric Reactive Energy
+channel-type.growatt.advanced-electric-power.label = Electric Power
+channel-type.growatt.advanced-electric-va.label = Electric VA
+channel-type.growatt.advanced-electric-var.label = Electric Reactive Power
+channel-type.growatt.advanced-electric-voltage.label = Electric Voltage
+channel-type.growatt.advanced-fault-code.label = Fault Code
+channel-type.growatt.advanced-outdoor-temperature.label = Outdoor Temperature
+channel-type.growatt.advanced-percent.label = Percentage
+channel-type.growatt.advanced-status-code.label = Status Code
+channel-type.growatt.advanced-work-time.label = Work Time
+channel-type.growatt.system-status-code.label = Status Code
+
+# discovery
+
+discovery.growatt-inverter = Growatt Inverter
+
+# thing status
+
+status.awaiting-data = Waiting for data from Grott application
+status.awaiting-data-timeout = Timed out waiting for data from Grott application
+
+# actions
+
+actions.battery-program.label = Setup Battery Program
+actions.battery-program.description = Setup the battery charging / discharging program
+actions.enable-ac-charging.label = Enable AC Charging
+actions.enable-ac-charging.description = Enable the battery to be charged from AC supply
+actions.enable-program.label = Program Enable
+actions.enable-program.description = Enable / disable the battery charging / discharging program
+actions.power-level.label = Charge / Discharge Power
+actions.power-level.description = The rate of battery charging / discharging (1%..100%)
+actions.program-mode.label = Battery Program Mode
+actions.program-mode.description = Select Load First (0), Battery First (2), Grid First (2)
+actions.start-time.label = Start Time
+actions.start-time.description = The time when the program shall start
+actions.stop-soc.label = Stop SOC Level
+actions.stop-soc.description = The battery SOC target for when the program is completed
+actions.stop-time.label = Stop Time
+actions.stop-time.description = The time when the program shall stop
diff --git a/bundles/org.openhab.binding.growatt/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.growatt/src/main/resources/OH-INF/thing/thing-types.xml
new file mode 100644
index 00000000000..a736a13e8fd
--- /dev/null
+++ b/bundles/org.openhab.binding.growatt/src/main/resources/OH-INF/thing/thing-types.xml
@@ -0,0 +1,551 @@
+
+
+
+
+
+
+ Bridge Thing for Growatt Binding
+
+
+
+
+ User name to login to the Shine App.
+ true
+
+
+ password
+
+ Password to login to the Shine App.
+ true
+
+
+
+
+
+
+
+
+
+
+
+ Inverter Thing for Growatt Binding
+
+
+
+
+
+ Status code of the inverter.
+
+
+
+
+
+ Power from solar panels.
+
+
+
+
+
+ Voltage from solar panel string #1.
+
+
+
+ Voltage from solar panel string #2.
+
+
+
+
+ Current from solar panel string #1.
+
+
+
+ Current from solar panel string #2.
+
+
+
+
+ Power from solar panel string #1.
+
+
+
+ Power from solar panel string #2.
+
+
+
+
+
+ Frequency of the grid.
+
+
+
+ Voltage of the grid (phase #R).
+
+
+
+ Voltage of the grid phase #S.
+
+
+
+ Voltage of the grid phase #T.
+
+
+
+ Voltage of the grid phases #RS.
+
+
+
+ Voltage of the grid phases #ST.
+
+
+
+ Voltage of the grid phases #TR.
+
+
+
+
+
+ AC current from inverter (phase #R).
+
+
+
+ AC current from inverter phase #S.
+
+
+
+ AC current from inverter phase #T.
+
+
+
+
+ AC power the inverter (total).
+
+
+
+ AC power from inverter (phase #R).
+
+
+
+ AC power from inverter phase #S.
+
+
+
+ AC power from inverter phase #T.
+
+
+
+
+ AC VA produced by inverter.
+
+
+
+
+
+ Charge power to battery.
+
+
+
+ Charge current to battery.
+
+
+
+ Discharge power from battery.
+
+
+
+ Discharge VA from battery.
+
+
+
+
+
+ Power exported to grid.
+
+
+
+ Power exported to grid phase #R.
+
+
+
+ Power exported to grid phase #S.
+
+
+
+ Power exported to grid phase #T.
+
+
+
+
+
+ Power imported.
+
+
+
+ Power imported phase #R.
+
+
+
+ Power imported phase #S.
+
+
+
+ Power imported phase #T.
+
+
+
+
+
+ Power supplied to load.
+
+
+
+ Power supplied to load phase #R.
+
+
+
+ Power supplied to load phase #S.
+
+
+
+ Power supplied to load phase #T.
+
+
+
+
+
+ Inverter output energy produced today.
+
+
+
+ Total inverter output energy produced.
+
+
+
+
+
+ Solar DC energy collected.
+
+
+
+ Solar DC energy collected by string #1 to grid today.
+
+
+
+ Solar DC energy collected by string #2 to grid today.
+
+
+
+
+ Total solar energy supplied to grid.
+
+
+
+ Total solar DC collected by string #1.
+
+
+
+ Total solar DC collected by string #2.
+
+
+
+
+
+ Energy exported to grid today.
+
+
+
+ Total energy exported to grid.
+
+
+
+
+
+ Energy imported from grid today.
+
+
+
+ Total energy imported from grid.
+
+
+
+
+
+ Energy supplied to load today.
+
+
+
+ Total energy supplied to load.
+
+
+
+
+
+ Energy imported from grid to charge battery today.
+
+
+
+ Total energy imported from grid to charge battery.
+
+
+
+
+
+ Energy from inverter to charge battery today.
+
+
+
+ Total energy from inverter to charge battery.
+
+
+
+
+
+ Energy consumed from battery today.
+
+
+
+ Total energy consumed from battery.
+
+
+
+
+
+ Total inverter working time.
+
+
+
+
+
+ P Bus voltage.
+
+
+
+ N Bus voltage.
+
+
+
+ SP Bus voltage.
+
+
+
+
+
+ Temperature of the solar panels (string #1).
+
+
+
+ Temperature of the IPM.
+
+
+
+ Boost temperature.
+
+
+
+ Temperature #4.
+
+
+
+ Temperature of the solar panels (string #2).
+
+
+
+
+
+ Type code of the battery.
+
+
+
+ Battery temperature.
+
+
+
+ Battery voltage.
+
+
+
+ Battery display code.
+
+
+
+ Battery state of charge.
+
+
+
+
+
+ System fault code #0.
+
+
+
+ System fault code #1.
+
+
+
+ System fault code #2.
+
+
+
+ System fault code #3.
+
+
+
+ System fault code #4.
+
+
+
+ System fault code #5.
+
+
+
+ System fault code #6.
+
+
+
+ System fault code #7.
+
+
+
+
+
+ System work mode code.
+
+
+
+ Solar panel display status code.
+
+
+
+ Constant power OK code.
+
+
+
+ Percent of full load.
+
+
+
+
+
+ Reactive power output.
+
+
+
+ Reactive energy supplied today.
+
+
+
+ Total reactive energy supplied.
+
+
+
+
+
+
+
+ Id (serial number) of the inverter.
+
+
+
+
+
+
+
+ Number:Dimensionless
+
+ Status
+
+
+
+
+ Number:Dimensionless
+
+ Status
+
+
+
+
+ Number:Dimensionless
+
+ Siren
+
+
+
+
+ Number:Dimensionless
+
+
+
+
+
+ Number:Frequency
+
+ Energy
+
+
+
+
+ Number:Power
+
+ Energy
+
+
+
+
+ Number:Time
+
+ Time
+
+
+
+
+ Number:Power
+
+ Energy
+
+
+
+
+ Number:ElectricCurrent
+
+ Energy
+
+
+
+
+ Number:ElectricPotential
+
+ Energy
+
+
+
+
+ Number:Energy
+
+ Energy
+
+
+
+
+ Number:Power
+
+ Energy
+
+
+
+
+ Number:Energy
+
+ Energy
+
+
+
+
+ Number:Temperature
+
+ Temperature
+
+
+
+
diff --git a/bundles/org.openhab.binding.growatt/src/test/java/org/openhab/binding/growatt/test/GrowattTest.java b/bundles/org.openhab.binding.growatt/src/test/java/org/openhab/binding/growatt/test/GrowattTest.java
new file mode 100644
index 00000000000..474092ae622
--- /dev/null
+++ b/bundles/org.openhab.binding.growatt/src/test/java/org/openhab/binding/growatt/test/GrowattTest.java
@@ -0,0 +1,386 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.growatt.test;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.*;
+
+import java.io.BufferedReader;
+import java.io.FileNotFoundException;
+import java.io.FileReader;
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.stream.Collectors;
+
+import javax.net.ssl.SSLSession;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.util.ssl.SslContextFactory;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.growatt.internal.GrowattChannels;
+import org.openhab.binding.growatt.internal.GrowattChannels.UoM;
+import org.openhab.binding.growatt.internal.cloud.GrowattCloud;
+import org.openhab.binding.growatt.internal.config.GrowattBridgeConfiguration;
+import org.openhab.binding.growatt.internal.dto.GrottDevice;
+import org.openhab.binding.growatt.internal.dto.GrottValues;
+import org.openhab.binding.growatt.internal.dto.helper.GrottIntegerDeserializer;
+import org.openhab.binding.growatt.internal.dto.helper.GrottValuesHelper;
+import org.openhab.core.io.net.http.HttpClientFactory;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.unit.SIUnits;
+import org.openhab.core.library.unit.Units;
+import org.openhab.core.types.State;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+
+/**
+ * The {@link GrowattTest} is a JUnit test suite for the Growatt binding.
+ *
+ * @author Andrew Fiddian-Green - Initial contribution
+ */
+@NonNullByDefault
+public class GrowattTest {
+
+ private final Gson gson = new GsonBuilder().registerTypeAdapter(Integer.class, new GrottIntegerDeserializer())
+ .create();
+
+ /**
+ * Load a (JSON) string from a file
+ *
+ * @throws IOException
+ * @throws FileNotFoundException
+ */
+ private String load(String fileName) throws FileNotFoundException, IOException {
+ try (FileReader file = new FileReader(String.format("src/test/resources/%s.json", fileName));
+ BufferedReader reader = new BufferedReader(file)) {
+ StringBuilder builder = new StringBuilder();
+ String line;
+ while ((line = reader.readLine()) != null) {
+ builder.append(line).append("\n");
+ }
+ return builder.toString();
+ }
+ }
+
+ /**
+ * Load a GrottValues class from a JSON payload.
+ *
+ * @param fileName the file containing the JSON payload.
+ * @return a GrottValues DTO.
+ * @throws IOException
+ * @throws FileNotFoundException
+ */
+ private GrottValues loadGrottValues(String fileName) throws FileNotFoundException, IOException {
+ String json = load(fileName);
+ GrottDevice device = gson.fromJson(json, GrottDevice.class);
+ assertNotNull(device);
+ GrottValues grottValues = device.getValues();
+ assertNotNull(grottValues);
+ return grottValues;
+ }
+
+ @Test
+ void testGrottValuesAccessibility() throws FileNotFoundException, IOException {
+ testGrottValuesAccessibility("simple");
+ testGrottValuesAccessibility("sph");
+ }
+
+ /**
+ * For the given JSON file, test that GrottValues implements the same fields as the Map returned from
+ * GrowattChannels.getMap(). Test that all fields can be accessed and that they are either null or an Integer
+ * instance.
+ *
+ * @param fileName the name of the JSON file to be tested.
+ * @throws IOException
+ * @throws FileNotFoundException
+ */
+ private void testGrottValuesAccessibility(String fileName) throws FileNotFoundException, IOException {
+ GrottValues grottValues = loadGrottValues(fileName);
+
+ List fields = Arrays.asList(GrottValues.class.getFields()).stream().map(f -> f.getName())
+ .collect(Collectors.toList());
+
+ // test that the GrottValues DTO has identical field names to the CHANNEL_ID_UOM_MAP channel ids
+ for (String channel : GrowattChannels.getMap().keySet()) {
+ assertTrue(fields.contains(GrottValues.getFieldName(channel)));
+ }
+
+ // test that the CHANNEL_ID_UOM_MAP has identical channel ids to the GrottValues DTO field names
+ for (String field : fields) {
+ assertTrue(GrowattChannels.getMap().containsKey(GrottValues.getChannelId(field)));
+ }
+
+ // test that the CHANNEL_ID_UOM_MAP and the GrottValues DTO have the same number of fields resp. channel ids
+ assertEquals(fields.size(), GrowattChannels.getMap().size());
+ List errors = new ArrayList<>();
+
+ for (Entry entry : GrowattChannels.getMap().entrySet()) {
+ String channelId = entry.getKey();
+ Field field;
+ // test that the field can be accessed
+ try {
+ field = GrottValues.class.getField(GrottValues.getFieldName(channelId));
+ } catch (NoSuchFieldException | SecurityException e) {
+ String msg = e.getMessage();
+ errors.add(msg != null ? msg : e.getClass().getName());
+ continue;
+ }
+ // test that the field value is either null or an Integer
+ try {
+ Object value = field.get(grottValues);
+ assertTrue(value == null || (value instanceof Integer));
+ } catch (IllegalArgumentException | IllegalAccessException e) {
+ String msg = e.getMessage();
+ errors.add(msg != null ? msg : e.getClass().getName());
+ continue;
+ }
+ }
+ if (!errors.isEmpty()) {
+ fail(errors.toString());
+ }
+ }
+
+ /**
+ * Spot checks to test that GrottValues is loaded with the correct contents from the "simple" JSON file.
+ *
+ * @throws IOException
+ * @throws FileNotFoundException
+ * @throws IllegalArgumentException
+ * @throws IllegalAccessException
+ * @throws SecurityException
+ * @throws NoSuchFieldException
+ */
+ @Test
+ void testGrottValuesContents() throws FileNotFoundException, IOException, NoSuchFieldException, SecurityException,
+ IllegalAccessException, IllegalArgumentException {
+ GrottValues grottValues = loadGrottValues("simple");
+
+ assertEquals(1, grottValues.system_status);
+ assertEquals(1622, grottValues.pv_power);
+ assertEquals(4997, grottValues.grid_frequency);
+ assertEquals(2353, grottValues.grid_voltage_r);
+ assertEquals(7, grottValues.inverter_current_r);
+ assertEquals(1460, grottValues.inverter_power);
+ assertEquals(1460, grottValues.inverter_power_r);
+ assertEquals(273, grottValues.pv_temperature);
+ assertEquals(87, grottValues.inverter_energy_today);
+ assertEquals(43265, grottValues.inverter_energy_total);
+ assertEquals(90, grottValues.pv1_energy_today);
+ assertEquals(45453, grottValues.pv1_energy_total);
+ assertEquals(45453, grottValues.pv_energy_total);
+ assertEquals(0, grottValues.pv2_voltage);
+ assertEquals(0, grottValues.pv2_current);
+ assertEquals(0, grottValues.pv2_power);
+ assertEquals(65503878, grottValues.total_work_time);
+
+ Map> channelStates = null;
+ channelStates = GrottValuesHelper.getChannelStates(grottValues);
+
+ assertNotNull(channelStates);
+ assertEquals(29, channelStates.size());
+
+ channelStates.forEach((channelId, state) -> {
+ assertTrue(state instanceof QuantityType>);
+ });
+
+ assertEquals(QuantityType.ONE, channelStates.get("system-status"));
+ assertEquals(QuantityType.valueOf(162.2, Units.WATT), channelStates.get("pv-power"));
+ assertEquals(QuantityType.valueOf(49.97, Units.HERTZ), channelStates.get("grid-frequency"));
+ assertEquals(QuantityType.valueOf(235.3, Units.VOLT), channelStates.get("grid-voltage-r"));
+ assertEquals(QuantityType.valueOf(0.7, Units.AMPERE), channelStates.get("inverter-current-r"));
+ assertEquals(QuantityType.valueOf(146, Units.WATT), channelStates.get("inverter-power"));
+ assertEquals(QuantityType.valueOf(146, Units.WATT), channelStates.get("inverter-power-r"));
+ assertEquals(QuantityType.valueOf(27.3, SIUnits.CELSIUS), channelStates.get("pv-temperature"));
+ assertEquals(QuantityType.valueOf(8.7, Units.KILOWATT_HOUR), channelStates.get("inverter-energy-today"));
+ assertEquals(QuantityType.valueOf(4326.5, Units.KILOWATT_HOUR), channelStates.get("inverter-energy-total"));
+ assertEquals(QuantityType.valueOf(9, Units.KILOWATT_HOUR), channelStates.get("pv1-energy-today"));
+ assertEquals(QuantityType.valueOf(4545.3, Units.KILOWATT_HOUR), channelStates.get("pv1-energy-total"));
+ assertEquals(QuantityType.valueOf(4545.3, Units.KILOWATT_HOUR), channelStates.get("pv-energy-total"));
+ assertEquals(QuantityType.valueOf(0, Units.VOLT), channelStates.get("pv2-voltage"));
+ assertEquals(QuantityType.valueOf(0, Units.AMPERE), channelStates.get("pv2-current"));
+ assertEquals(QuantityType.valueOf(0, Units.WATT), channelStates.get("pv2-power"));
+ State state = channelStates.get("total-work-time");
+ assertTrue(state instanceof QuantityType>);
+ if (state instanceof QuantityType>) {
+ QuantityType> seconds = ((QuantityType>) state).toUnit(Units.SECOND);
+ assertNotNull(seconds);
+ assertEquals(QuantityType.valueOf(32751939, Units.SECOND).doubleValue(), seconds.doubleValue(), 0.1);
+ }
+
+ assertNull(channelStates.get("aardvark"));
+ }
+
+ @Test
+ void testJsonFieldsMappedToDto() throws FileNotFoundException, IOException {
+ testJsonFieldsMappedToDto("simple");
+ testJsonFieldsMappedToDto("sph");
+ }
+
+ /**
+ * For the given JSON test file name, check that each field in its JSON is mapped to precisely one field in the
+ * values DTO.
+ *
+ * @param fileName the name of the JSON file to be tested.
+ * @throws IOException
+ * @throws FileNotFoundException
+ */
+ private void testJsonFieldsMappedToDto(String fileName) throws FileNotFoundException, IOException {
+ Field[] fields = GrottValues.class.getFields();
+ String json = load(fileName);
+ JsonParser.parseString(json).getAsJsonObject().get("values").getAsJsonObject().entrySet().forEach(e -> {
+ String key = e.getKey();
+ if (!"datalogserial".equals(key) && !"pvserial".equals(key)) {
+ JsonObject testJsonObject = new JsonObject();
+ testJsonObject.add(key, e.getValue());
+ GrottValues testDto = gson.fromJson(testJsonObject, GrottValues.class);
+ int mappedFieldCount = 0;
+ List errors = new ArrayList<>();
+ for (Field field : fields) {
+ try {
+ if (field.get(testDto) != null) {
+ mappedFieldCount++;
+ }
+ } catch (IllegalAccessException | IllegalArgumentException ex) {
+ String msg = ex.getMessage();
+ errors.add(msg != null ? msg : ex.getClass().getName());
+ }
+ }
+ if (!errors.isEmpty()) {
+ fail(errors.toString());
+ }
+ assertEquals(1, mappedFieldCount);
+ }
+ });
+ }
+
+ /**
+ * Test the Growatt remote cloud API server.
+ * Will not run unless actual user credentials are provided.
+ *
+ * @throws Exception
+ */
+ @Test
+ void testServer() throws Exception {
+ GrowattBridgeConfiguration configuration = new GrowattBridgeConfiguration();
+ String deviceId = "";
+
+ /*
+ * To test on an actual inverter, populate its plant data and user credentials below.
+ *
+ * configuration.userName = "aa";
+ * configuration.password ="bb";
+ * deviceId = "cc";
+ *
+ */
+
+ if (configuration.userName == null) {
+ return;
+ }
+
+ SslContextFactory.Client sslContextFactory = new SslContextFactory.Client.Client();
+ sslContextFactory.setHostnameVerifier((@Nullable String host, @Nullable SSLSession session) -> true);
+ sslContextFactory.setValidatePeerCerts(false);
+
+ HttpClientFactory httpClientFactory = mock(HttpClientFactory.class);
+ when(httpClientFactory.createHttpClient(anyString())).thenReturn(new HttpClient(sslContextFactory));
+
+ try (GrowattCloud api = new GrowattCloud(configuration, httpClientFactory)) {
+ Integer programMode = GrowattCloud.ProgramMode.BATTERY_FIRST.ordinal();
+ Integer chargingPower = 97;
+ Integer targetSOC = 23;
+ Boolean allowAcCharging = false;
+ String startTime = "01:16";
+ String stopTime = "02:17";
+ Boolean programEnable = false;
+ api.setupBatteryProgram(deviceId, programMode, chargingPower, targetSOC, allowAcCharging, startTime,
+ stopTime, programEnable);
+ Map result = api.getDeviceSettings(deviceId);
+ assertFalse(result.isEmpty());
+ assertEquals(chargingPower, GrowattCloud.mapGetInteger(result, GrowattCloud.CHARGE_PROGRAM_POWER));
+ assertEquals(targetSOC, GrowattCloud.mapGetInteger(result, GrowattCloud.CHARGE_PROGRAM_TARGET_SOC));
+ assertEquals(allowAcCharging,
+ GrowattCloud.mapGetBoolean(result, GrowattCloud.CHARGE_PROGRAM_ALLOW_AC_CHARGING));
+ assertEquals(GrowattCloud.localTimeOf(startTime),
+ GrowattCloud.mapGetLocalTime(result, GrowattCloud.CHARGE_PROGRAM_START_TIME));
+ assertEquals(GrowattCloud.localTimeOf(stopTime),
+ GrowattCloud.mapGetLocalTime(result, GrowattCloud.CHARGE_PROGRAM_STOP_TIME));
+ assertEquals(programEnable, GrowattCloud.mapGetBoolean(result, GrowattCloud.CHARGE_PROGRAM_ENABLE));
+
+ chargingPower = 100;
+ targetSOC = 20;
+ allowAcCharging = true;
+ startTime = "00:15";
+ stopTime = "06:45";
+ programEnable = true;
+ api.setupBatteryProgram(deviceId, programMode, chargingPower, targetSOC, allowAcCharging, startTime,
+ stopTime, programEnable);
+ result = api.getDeviceSettings(deviceId);
+ assertFalse(result.isEmpty());
+ assertEquals(chargingPower, GrowattCloud.mapGetInteger(result, GrowattCloud.CHARGE_PROGRAM_POWER));
+ assertEquals(targetSOC, GrowattCloud.mapGetInteger(result, GrowattCloud.CHARGE_PROGRAM_TARGET_SOC));
+ assertEquals(allowAcCharging,
+ GrowattCloud.mapGetBoolean(result, GrowattCloud.CHARGE_PROGRAM_ALLOW_AC_CHARGING));
+ assertEquals(GrowattCloud.localTimeOf(startTime),
+ GrowattCloud.mapGetLocalTime(result, GrowattCloud.CHARGE_PROGRAM_START_TIME));
+ assertEquals(GrowattCloud.localTimeOf(stopTime),
+ GrowattCloud.mapGetLocalTime(result, GrowattCloud.CHARGE_PROGRAM_STOP_TIME));
+ assertEquals(programEnable, GrowattCloud.mapGetBoolean(result, GrowattCloud.CHARGE_PROGRAM_ENABLE));
+ }
+ }
+
+ @Test
+ void testThreePhaseGrottValuesContents() throws FileNotFoundException, IOException, NoSuchFieldException,
+ SecurityException, IllegalAccessException, IllegalArgumentException {
+ GrottValues grottValues = loadGrottValues("3phase");
+ assertNotNull(grottValues);
+
+ Map> channelStates = GrottValuesHelper.getChannelStates(grottValues);
+ assertNotNull(channelStates);
+ assertEquals(64, channelStates.size());
+
+ assertEquals(QuantityType.valueOf(-36.5, Units.WATT), channelStates.get("inverter-power"));
+ assertEquals(QuantityType.valueOf(11, Units.PERCENT), channelStates.get("battery-soc"));
+ assertEquals(QuantityType.valueOf(408.4, Units.VOLT), channelStates.get("grid-voltage-rs"));
+ assertEquals(QuantityType.valueOf(326.5, Units.VOLT), channelStates.get("n-bus-voltage"));
+ assertEquals(QuantityType.valueOf(404.1, Units.VOLT), channelStates.get("battery-voltage"));
+ }
+
+ @Test
+ void testMeterGrottValuesContents() throws FileNotFoundException, IOException, NoSuchFieldException,
+ SecurityException, IllegalAccessException, IllegalArgumentException {
+ GrottValues grottValues = loadGrottValues("meter");
+ assertNotNull(grottValues);
+
+ Map> channelStates = GrottValuesHelper.getChannelStates(grottValues);
+ assertNotNull(channelStates);
+ assertEquals(16, channelStates.size());
+
+ assertEquals(QuantityType.valueOf(809.8, Units.WATT), channelStates.get("import-power"));
+ assertEquals(QuantityType.valueOf(171.0, Units.WATT), channelStates.get("import-power-s"));
+ assertEquals(QuantityType.valueOf(237.4, Units.VOLT), channelStates.get("grid-voltage-s"));
+ assertEquals(QuantityType.valueOf(409.5, Units.VOLT), channelStates.get("grid-voltage-rs"));
+ assertEquals(QuantityType.valueOf(1.5, Units.AMPERE), channelStates.get("inverter-current-s"));
+ }
+}
diff --git a/bundles/org.openhab.binding.growatt/src/test/resources/3phase.json b/bundles/org.openhab.binding.growatt/src/test/resources/3phase.json
new file mode 100644
index 00000000000..ee5371721d2
--- /dev/null
+++ b/bundles/org.openhab.binding.growatt/src/test/resources/3phase.json
@@ -0,0 +1,158 @@
+{
+ "device": "KLN0D6L034",
+ "time": "2023-12-26T21:48:33",
+ "buffered": "no",
+ "values": {
+ "pvserial": "KLN0D6L034",
+ "pvstatus": 1,
+ "pvpowerin": 0,
+ "pv1voltage": 669,
+ "pv1current": 0,
+ "pv1watt": 0,
+ "pv2voltage": 695,
+ "pv2current": 0,
+ "pv2watt": 0,
+ "pv3voltage": 0,
+ "pv3current": 0,
+ "pv3watt": 0,
+ "pv4voltage": 0,
+ "pv4current": 0,
+ "pv4watt": 0,
+ "pvpowerout": 4294966929,
+ "pvfrequentie": 5001,
+ "pvgridvoltage": 2359,
+ "pvgridcurrent": 7,
+ "pvgridpower": 1651,
+ "pvgridvoltage2": 2367,
+ "pvgridcurrent2": 8,
+ "pvgridpower2": 1893,
+ "pvgridvoltage3": 2379,
+ "pvgridcurrent3": 8,
+ "pvgridpower3": 1903,
+ "vacrs": 4084,
+ "vacst": 4118,
+ "vactr": 4104,
+ "ptousertotal": 8099,
+ "ptogridtotal": 0,
+ "ptoloadtotal": 8239,
+ "totworktime": 79652,
+ "pvenergytoday": 178,
+ "pvenergytotal": 178,
+ "epvtotal ": 162,
+ "epv1today ": 79,
+ "epv1total": 79,
+ "epv2today": 83,
+ "epv2total": 83,
+ "epv3today": 0,
+ "epv3total": 0,
+ "etousertoday": 64,
+ "etousertotal": 64,
+ "etogridtoday": 1,
+ "etogridtotal": 1,
+ "eloadtoday": 237,
+ "eloadtotal": 0,
+ "deratingmode": 0,
+ "iso": 15997,
+ "dcir": 0,
+ "dcis": 0,
+ "dcit": 0,
+ "gfci": 137645,
+ "pvtemperature": 296,
+ "pvipmtemperature": 410,
+ "temp3": 296,
+ "temp4": 0,
+ "temp5": 319,
+ "pbusvoltage": 3311,
+ "nbusvoltage": 3265,
+ "ipf": 20000,
+ "realoppercent": 0,
+ "opfullwatt": 150000,
+ "standbyflag": 0,
+ "faultcode": 0,
+ "warningcode": 0,
+ "systemfaultword0": 0,
+ "systemfaultword1": 0,
+ "systemfaultword2": 0,
+ "systemfaultword3": 0,
+ "systemfaultword4": 0,
+ "systemfaultword5": 0,
+ "systemfaultword6": 0,
+ "systemfaultword7": 0,
+ "invstartdelaytime": 60,
+ "bdconoffstate": 1,
+ "drycontactstate": 0,
+ "edischrtoday": 103,
+ "edischrtotal": 1843,
+ "echrtoday": 91,
+ "echrtotal": 3005,
+ "eacchrtoday": 5,
+ "eacchrtotal": 5,
+ "priority": 1,
+ "epsfac": 0,
+ "epsvac1": 0,
+ "epsiac1": 0,
+ "epspac1": 0,
+ "epsvac2": 0,
+ "epsiac2": 0,
+ "epspac2": 0,
+ "epsvac3": 0,
+ "epsiac3": 0,
+ "epspac3": 0,
+ "epspac": 0,
+ "loadpercent": 0,
+ "pf": 10000,
+ "dcv": 0,
+ "bdc1_sysstatemode": 513,
+ "bdc1_faultcode": 0,
+ "bdc1_warncode": 701,
+ "bdc1_vbat": 6582,
+ "bdc1_ibat": 0,
+ "bdc1_soc": 11,
+ "bdc1_vbus1": 6582,
+ "bdc1_vbus2": 3303,
+ "bdc1_ibb": 0,
+ "bdc1_illc": 0,
+ "bdc1_tempa": 409,
+ "bdc1_tempb": 291,
+ "bdc1_pdischr": 100,
+ "bdc1_pchr": 0,
+ "bdc1_edischrtotal": 1843,
+ "bdc1_echrtotal": 3005,
+ "bdc1_flag": 1,
+ "bdc2_sysstatemode": 17,
+ "bdc2_faultcode": 12,
+ "bdc2_warncode": 248,
+ "bdc2_vbat": 266,
+ "bdc2_ibat": 223,
+ "bdc2_soc": 19,
+ "bdc2_vbus1": 49,
+ "bdc2_vbus2": 11,
+ "bdc2_ibb": 11,
+ "bdc2_illc": 4,
+ "bdc2_tempa": 0,
+ "bdc2_tempb": 394,
+ "bdc2_pdischr": 26214400,
+ "bdc2_pchr": 0,
+ "bdc2_edischrtotal": 0,
+ "bdc2_echrtotal": 0,
+ "bdc2_flag": 0,
+ "bms_status": 4,
+ "bms_error": 0,
+ "bms_warninfo": 0,
+ "bms_soc": 11,
+ "bms_batteryvolt": 4041,
+ "bms_batterycurr": 0,
+ "bms_batterytemp": 0,
+ "bms_maxcurr": 2200,
+ "bms_deltavolt": 2200,
+ "bms_cyclecnt": 0,
+ "bms_soh": 100,
+ "bms_constantvolt": 568,
+ "bms_bms_info": 464,
+ "bms_packinfo": 0,
+ "bms_usingcap": 0,
+ "bms_fw": 1400,
+ "bms_mcuversion": 0,
+ "bms_commtype": 1
+ }
+}
diff --git a/bundles/org.openhab.binding.growatt/src/test/resources/meter.json b/bundles/org.openhab.binding.growatt/src/test/resources/meter.json
new file mode 100644
index 00000000000..5388464ac96
--- /dev/null
+++ b/bundles/org.openhab.binding.growatt/src/test/resources/meter.json
@@ -0,0 +1,39 @@
+{
+ "device": "GZL0DA804M",
+ "time": "2023-12-26T21:48:36",
+ "buffered": "no",
+ "values": {
+ "datalogserial": "GZL0DA804M",
+ "pvserial": "KLN0D6L034",
+ "voltage_l1": 2355,
+ "voltage_l2": 2374,
+ "voltage_l3": 2376,
+ "Current_l1": 22,
+ "Current_l2": 15,
+ "Current_l3": 12,
+ "act_power_l1": 4909,
+ "act_power_l2": 1710,
+ "act_power_l3": 1478,
+ "app_power_l1": 5058,
+ "app_power_l2": 3321,
+ "app_power_l3": 2712,
+ "react_power_l1": -1222,
+ "react_power_l2": -2847,
+ "react_power_l3": -2275,
+ "powerfactor_l1": 936,
+ "powerfactor_l2": 481,
+ "powerfactor_l3": 502,
+ "pos_rev_act_power": 8098,
+ "pos_act_power": 8098,
+ "rev_act_power": 8098,
+ "app_power": 11091,
+ "react_power": -6346,
+ "powerfactor": 690,
+ "frequency": 500,
+ "L1-2_voltage": 4095,
+ "L2-3_voltage": 4113,
+ "L3-1_voltage": 4097,
+ "pos_act_energy": 9587,
+ "rev_act_energy": 1387
+ }
+}
diff --git a/bundles/org.openhab.binding.growatt/src/test/resources/simple.json b/bundles/org.openhab.binding.growatt/src/test/resources/simple.json
new file mode 100644
index 00000000000..22b5e4f1438
--- /dev/null
+++ b/bundles/org.openhab.binding.growatt/src/test/resources/simple.json
@@ -0,0 +1,36 @@
+{
+ "device": "INVERTID",
+ "time": "2021-02-13T16:34:28",
+ "buffered": "no",
+ "values": {
+ "pvstatus": 1,
+ "pvpowerin": 1622,
+ "pv1voltage": 2475,
+ "pv1current": 6,
+ "pv1watt": 1622,
+ "pv2voltage": 0,
+ "pv2current": 0,
+ "pv2watt": 0,
+ "pvpowerout": 1460,
+ "pvfrequentie": 4997,
+ "pvgridvoltage": 2353,
+ "pvgridcurrent": 7,
+ "pvgridpower": 1460,
+ "pvgridvoltage2": 0,
+ "pvgridcurrent2": 0,
+ "pvgridpower2": 0,
+ "pvgridvoltage3": 0,
+ "pvgridcurrent3": 0,
+ "pvgridpower3": 0,
+ "pvenergytoday": 87,
+ "pvenergytotal": 43265,
+ "totworktime": 65503878,
+ "pvtemperature": 273,
+ "pvipmtemperature": 0,
+ "epv1today": 90,
+ "epv1total": 45453,
+ "epv2today": 0,
+ "epv2total": 0,
+ "epvtotal": 45453
+ }
+}
diff --git a/bundles/org.openhab.binding.growatt/src/test/resources/sph.json b/bundles/org.openhab.binding.growatt/src/test/resources/sph.json
new file mode 100644
index 00000000000..a6ce013394a
--- /dev/null
+++ b/bundles/org.openhab.binding.growatt/src/test/resources/sph.json
@@ -0,0 +1,75 @@
+{
+ "device": "KUM0CLU03Y",
+ "time": "2023-09-10T12:23:13",
+ "buffered": "no",
+ "values": {
+ "datalogserial": "GPG0DBJ05N",
+ "pvserial": "KUM0CLU03Y",
+ "pvstatus": 5,
+ "pvpowerin": 16100,
+ "pv1voltage": 1805,
+ "pv1current": 89,
+ "pv1watt": 16169,
+ "pv2voltage": 0,
+ "pv2current": 0,
+ "pv2watt": 0,
+ "pvpowerout": 4285,
+ "pvfrequentie": 5003,
+ "pvgridvoltage": 2443,
+ "pvgridcurrent": 18,
+ "pvgridpower": 4609,
+ "pvgridvoltage2": 0,
+ "pvgridcurrent2": 0,
+ "pvgridpower2": 0,
+ "pvgridvoltage3": 0,
+ "pvgridcurrent3": 0,
+ "pvgridpower3": 0,
+ "totworktime": 6723587,
+ "eactoday": 27,
+ "pvenergytoday": 27,
+ "eactotal": 3571,
+ "epvtotal": 4105,
+ "epv1today": 64,
+ "epv1total": 4057,
+ "epv2today": 0,
+ "epv2total": 0,
+ "pvtemperature": 576,
+ "pvipmtemperature": 527,
+ "pvboosttemp": 572,
+ "bat_dsp": 541,
+ "eacharge_today": 10,
+ "eacharge_total": 277,
+ "batterytype": 1,
+ "uwsysworkmode": 5,
+ "systemfaultword0": 0,
+ "systemfaultword1": 0,
+ "systemfaultword2": 0,
+ "systemfaultword3": 0,
+ "systemfaultword4": 0,
+ "systemfaultword5": 0,
+ "systemfaultword6": 0,
+ "systemfaultword7": 0,
+ "pdischarge1": 0,
+ "p1charge1": 10284,
+ "vbat": 539,
+ "SOC": 69,
+ "pactouserr": 0,
+ "pactousertot": 0,
+ "pactogridr": 0,
+ "pactogridtot": 0,
+ "plocaloadr": 5800,
+ "plocaloadtot": 5800,
+ "spdspstatus": 5,
+ "spbusvolt": 3290,
+ "etouser_tod": 54,
+ "etouser_tot": 2330,
+ "etogrid_tod": 1,
+ "etogrid_tot": 707,
+ "edischarge1_tod": 3,
+ "edischarge1_tot": 1652,
+ "eharge1_tod": 41,
+ "eharge1_tot": 1524,
+ "elocalload_tod": 80,
+ "elocalload_tot": 5856
+ }
+}
diff --git a/bundles/pom.xml b/bundles/pom.xml
index f97ad738a32..07b13fcb284 100644
--- a/bundles/pom.xml
+++ b/bundles/pom.xml
@@ -160,8 +160,9 @@
org.openhab.binding.globalcacheorg.openhab.binding.gpstrackerorg.openhab.binding.gree
- org.openhab.binding.groupepsaorg.openhab.binding.groheondus
+ org.openhab.binding.groupepsa
+ org.openhab.binding.growattorg.openhab.binding.guntamaticorg.openhab.binding.haassohnpelletstoveorg.openhab.binding.harmonyhub