From 6f7b5b5f315f3ae0f1f3302d7c752f5b1fd28fcd Mon Sep 17 00:00:00 2001
From: Andrew Fiddian-Green <software@whitebear.ch>
Date: Sat, 20 Jan 2024 13:25:07 +0000
Subject: [PATCH] [growatt] Binding for Growatt solar inverters (#15120)

* [growatt] initial contribution

Signed-off-by: Andrew Fiddian-Green <software@whitebear.ch>
---
 CODEOWNERS                                    |   1 +
 bom/openhab-addons/pom.xml                    |   5 +
 bundles/org.openhab.binding.growatt/NOTICE    |  13 +
 bundles/org.openhab.binding.growatt/README.md | 349 +++++++
 bundles/org.openhab.binding.growatt/pom.xml   |  17 +
 .../src/main/feature/feature.xml              |   9 +
 .../internal/GrowattBindingConstants.java     |  31 +
 .../growatt/internal/GrowattChannels.java     | 196 ++++
 .../internal/action/GrowattActions.java       |  76 ++
 .../internal/cloud/GrowattApiException.java   |  34 +
 .../growatt/internal/cloud/GrowattCloud.java  | 904 ++++++++++++++++++
 .../config/GrowattBridgeConfiguration.java    |  31 +
 .../config/GrowattInverterConfiguration.java  |  28 +
 .../discovery/GrowattDiscoveryService.java    |  68 ++
 .../growatt/internal/dto/GrottDevice.java     |  47 +
 .../growatt/internal/dto/GrottValues.java     | 188 ++++
 .../growatt/internal/dto/GrowattDevice.java   |  38 +
 .../growatt/internal/dto/GrowattPlant.java    |  38 +
 .../internal/dto/GrowattPlantList.java        |  46 +
 .../growatt/internal/dto/GrowattUser.java     |  32 +
 .../dto/helper/GrottIntegerDeserializer.java  |  48 +
 .../dto/helper/GrottValuesHelper.java         |  57 ++
 .../factory/GrowattHandlerFactory.java        | 153 +++
 .../handler/GrowattBridgeHandler.java         | 143 +++
 .../handler/GrowattInverterHandler.java       | 194 ++++
 .../internal/servlet/GrowattHttpServlet.java  |  86 ++
 .../src/main/resources/OH-INF/addon/addon.xml |  11 +
 .../resources/OH-INF/i18n/growatt.properties  | 241 +++++
 .../resources/OH-INF/thing/thing-types.xml    | 551 +++++++++++
 .../binding/growatt/test/GrowattTest.java     | 386 ++++++++
 .../src/test/resources/3phase.json            | 158 +++
 .../src/test/resources/meter.json             |  39 +
 .../src/test/resources/simple.json            |  36 +
 .../src/test/resources/sph.json               |  75 ++
 bundles/pom.xml                               |   3 +-
 35 files changed, 4331 insertions(+), 1 deletion(-)
 create mode 100644 bundles/org.openhab.binding.growatt/NOTICE
 create mode 100644 bundles/org.openhab.binding.growatt/README.md
 create mode 100644 bundles/org.openhab.binding.growatt/pom.xml
 create mode 100644 bundles/org.openhab.binding.growatt/src/main/feature/feature.xml
 create mode 100644 bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/GrowattBindingConstants.java
 create mode 100644 bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/GrowattChannels.java
 create mode 100644 bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/action/GrowattActions.java
 create mode 100644 bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/cloud/GrowattApiException.java
 create mode 100644 bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/cloud/GrowattCloud.java
 create mode 100644 bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/config/GrowattBridgeConfiguration.java
 create mode 100644 bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/config/GrowattInverterConfiguration.java
 create mode 100644 bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/discovery/GrowattDiscoveryService.java
 create mode 100644 bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrottDevice.java
 create mode 100644 bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrottValues.java
 create mode 100644 bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrowattDevice.java
 create mode 100644 bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrowattPlant.java
 create mode 100644 bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrowattPlantList.java
 create mode 100644 bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrowattUser.java
 create mode 100644 bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/helper/GrottIntegerDeserializer.java
 create mode 100644 bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/helper/GrottValuesHelper.java
 create mode 100644 bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/factory/GrowattHandlerFactory.java
 create mode 100644 bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/handler/GrowattBridgeHandler.java
 create mode 100644 bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/handler/GrowattInverterHandler.java
 create mode 100644 bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/servlet/GrowattHttpServlet.java
 create mode 100644 bundles/org.openhab.binding.growatt/src/main/resources/OH-INF/addon/addon.xml
 create mode 100644 bundles/org.openhab.binding.growatt/src/main/resources/OH-INF/i18n/growatt.properties
 create mode 100644 bundles/org.openhab.binding.growatt/src/main/resources/OH-INF/thing/thing-types.xml
 create mode 100644 bundles/org.openhab.binding.growatt/src/test/java/org/openhab/binding/growatt/test/GrowattTest.java
 create mode 100644 bundles/org.openhab.binding.growatt/src/test/resources/3phase.json
 create mode 100644 bundles/org.openhab.binding.growatt/src/test/resources/meter.json
 create mode 100644 bundles/org.openhab.binding.growatt/src/test/resources/simple.json
 create mode 100644 bundles/org.openhab.binding.growatt/src/test/resources/sph.json

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 @@
       <artifactId>org.openhab.binding.groupepsa</artifactId>
       <version>${project.version}</version>
     </dependency>
+    <dependency>
+      <groupId>org.openhab.addons.bundles</groupId>
+      <artifactId>org.openhab.binding.growatt</artifactId>
+      <version>${project.version}</version>
+    </dependency>
     <dependency>
       <groupId>org.openhab.addons.bundles</groupId>
       <artifactId>org.openhab.binding.guntamatic</artifactId>
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).                                                          |
+| powerLevel<sup>2)</sup>       | The percentage rate of battery (dis-)charge e.g. 100 - in 'Battery First' mode => charge power, otherwise => discharge power.                  |
+| stopSOC<sup>2)</sup>          | The battery SOC (state of charge) percentage when the program shall stop e.g. 20 - in 'Battery First' mode => max. SOC, otherwise => min. SOC. |
+| enableAcCharging<sup>2)</sup> | Allow the battery to be charged from the AC mains supply e.g. true, false.                                                                     |
+| startTime<sup>1,2)</sup>      | String representation of the local time when the program `time segment` shall start e.g. "00:15"                                               |
+| stopTime<sup>1,2)</sup>       | String representation of the local time when the program `time segment` shall stop e.g. "06:45"                                                |
+| enableProgram<sup>1,2)</sup>  | 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` - <u>**must**</u> 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 <u>**not**</u> 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 <u>**not**</u> 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<Energy>).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]" <energy> {channel="growatt:inverter:home:sph:charge-power"}
+
+// discarge item with negative value
+Number:Power Discharge_Power "Discharge Power [%.0f W]" <energy> {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/<username>/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=<username>  // your username
+WorkingDirectory=/home/<username>/grott/ // your home grott folder
+ExecStart=-/usr/bin/python3 -u /home/<username>/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 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
+  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+  <modelVersion>4.0.0</modelVersion>
+
+  <parent>
+    <groupId>org.openhab.addons.bundles</groupId>
+    <artifactId>org.openhab.addons.reactor.bundles</artifactId>
+    <version>4.2.0-SNAPSHOT</version>
+  </parent>
+
+  <artifactId>org.openhab.binding.growatt</artifactId>
+
+  <name>openHAB Add-ons :: Bundles :: Growatt Binding</name>
+
+</project>
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 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<features name="org.openhab.binding.growatt-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
+	<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
+
+	<feature name="openhab-binding-growatt" description="Growatt Binding" version="${project.version}">
+		<feature>openhab-runtime-base</feature>
+		<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.growatt/${project.version}</bundle>
+	</feature>
+</features>
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<String, UoM> CHANNEL_ID_UOM_MAP = Map.ofEntries(
+            // inverter state
+            new AbstractMap.SimpleEntry<String, UoM>("system-status", new UoM(Units.ONE, 1)),
+
+            // solar generation
+            new AbstractMap.SimpleEntry<String, UoM>("pv-power", new UoM(Units.WATT, 10)),
+
+            // electric data for strings #1 and #2
+            new AbstractMap.SimpleEntry<String, UoM>("pv1-voltage", new UoM(Units.VOLT, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("pv1-current", new UoM(Units.AMPERE, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("pv1-power", new UoM(Units.WATT, 10)),
+
+            new AbstractMap.SimpleEntry<String, UoM>("pv2-voltage", new UoM(Units.VOLT, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("pv2-current", new UoM(Units.AMPERE, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("pv2-power", new UoM(Units.WATT, 10)),
+
+            // grid electric data (1-phase resp. 3-phase)
+            new AbstractMap.SimpleEntry<String, UoM>("grid-frequency", new UoM(Units.HERTZ, 100)),
+
+            new AbstractMap.SimpleEntry<String, UoM>("grid-voltage-r", new UoM(Units.VOLT, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("grid-voltage-s", new UoM(Units.VOLT, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("grid-voltage-t", new UoM(Units.VOLT, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("grid-voltage-rs", new UoM(Units.VOLT, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("grid-voltage-st", new UoM(Units.VOLT, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("grid-voltage-tr", new UoM(Units.VOLT, 10)),
+
+            // inverter output
+            new AbstractMap.SimpleEntry<String, UoM>("inverter-current-r", new UoM(Units.AMPERE, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("inverter-current-s", new UoM(Units.AMPERE, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("inverter-current-t", new UoM(Units.AMPERE, 10)),
+
+            new AbstractMap.SimpleEntry<String, UoM>("inverter-power", new UoM(Units.WATT, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("inverter-power-r", new UoM(Units.WATT, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("inverter-power-s", new UoM(Units.WATT, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("inverter-power-t", new UoM(Units.WATT, 10)),
+
+            new AbstractMap.SimpleEntry<String, UoM>("inverter-va", new UoM(Units.VOLT_AMPERE, 10)),
+
+            // battery discharge / charge power
+            new AbstractMap.SimpleEntry<String, UoM>("charge-current", new UoM(Units.AMPERE, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("charge-power", new UoM(Units.WATT, 10)),
+
+            new AbstractMap.SimpleEntry<String, UoM>("discharge-power", new UoM(Units.WATT, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("discharge-va", new UoM(Units.VOLT_AMPERE, 10)),
+
+            // export power to grid
+            new AbstractMap.SimpleEntry<String, UoM>("export-power", new UoM(Units.WATT, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("export-power-r", new UoM(Units.WATT, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("export-power-s", new UoM(Units.WATT, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("export-power-t", new UoM(Units.WATT, 10)),
+
+            // power to user
+            new AbstractMap.SimpleEntry<String, UoM>("import-power", new UoM(Units.WATT, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("import-power-r", new UoM(Units.WATT, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("import-power-s", new UoM(Units.WATT, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("import-power-t", new UoM(Units.WATT, 10)),
+
+            // power to local
+            new AbstractMap.SimpleEntry<String, UoM>("load-power", new UoM(Units.WATT, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("load-power-r", new UoM(Units.WATT, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("load-power-s", new UoM(Units.WATT, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("load-power-t", new UoM(Units.WATT, 10)),
+
+            // inverter output energy
+            new AbstractMap.SimpleEntry<String, UoM>("inverter-energy-today", new UoM(Units.KILOWATT_HOUR, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("inverter-energy-total", new UoM(Units.KILOWATT_HOUR, 10)),
+
+            // solar DC input energy
+            new AbstractMap.SimpleEntry<String, UoM>("pv-energy-today", new UoM(Units.KILOWATT_HOUR, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("pv1-energy-today", new UoM(Units.KILOWATT_HOUR, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("pv2-energy-today", new UoM(Units.KILOWATT_HOUR, 10)),
+
+            new AbstractMap.SimpleEntry<String, UoM>("pv-energy-total", new UoM(Units.KILOWATT_HOUR, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("pv1-energy-total", new UoM(Units.KILOWATT_HOUR, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("pv2-energy-total", new UoM(Units.KILOWATT_HOUR, 10)),
+
+            // energy exported to grid
+            new AbstractMap.SimpleEntry<String, UoM>("export-energy-today", new UoM(Units.KILOWATT_HOUR, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("export-energy-total", new UoM(Units.KILOWATT_HOUR, 10)),
+
+            // energy imported from grid
+            new AbstractMap.SimpleEntry<String, UoM>("import-energy-today", new UoM(Units.KILOWATT_HOUR, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("import-energy-total", new UoM(Units.KILOWATT_HOUR, 10)),
+
+            // energy supplied to load
+            new AbstractMap.SimpleEntry<String, UoM>("load-energy-today", new UoM(Units.KILOWATT_HOUR, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("load-energy-total", new UoM(Units.KILOWATT_HOUR, 10)),
+
+            // energy imported to charge
+            new AbstractMap.SimpleEntry<String, UoM>("import-charge-energy-today", new UoM(Units.KILOWATT_HOUR, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("import-charge-energy-total", new UoM(Units.KILOWATT_HOUR, 10)),
+
+            // inverter energy to charge
+            new AbstractMap.SimpleEntry<String, UoM>("inverter-charge-energy-today", new UoM(Units.KILOWATT_HOUR, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("inverter-charge-energy-total", new UoM(Units.KILOWATT_HOUR, 10)),
+
+            // energy supplied from discharge
+            new AbstractMap.SimpleEntry<String, UoM>("discharge-energy-today", new UoM(Units.KILOWATT_HOUR, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("discharge-energy-total", new UoM(Units.KILOWATT_HOUR, 10)),
+
+            // inverter up time
+            new AbstractMap.SimpleEntry<String, UoM>("total-work-time", new UoM(Units.HOUR, 7200)),
+
+            // bus voltages
+            new AbstractMap.SimpleEntry<String, UoM>("p-bus-voltage", new UoM(Units.VOLT, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("n-bus-voltage", new UoM(Units.VOLT, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("sp-bus-voltage", new UoM(Units.VOLT, 10)),
+
+            // temperatures
+            new AbstractMap.SimpleEntry<String, UoM>("pv-temperature", new UoM(SIUnits.CELSIUS, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("pv-ipm-temperature", new UoM(SIUnits.CELSIUS, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("pv-boost-temperature", new UoM(SIUnits.CELSIUS, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("temperature-4", new UoM(SIUnits.CELSIUS, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("pv2-temperature", new UoM(SIUnits.CELSIUS, 10)),
+
+            // battery data
+            new AbstractMap.SimpleEntry<String, UoM>("battery-type", new UoM(Units.ONE, 1)),
+            new AbstractMap.SimpleEntry<String, UoM>("battery-voltage", new UoM(Units.VOLT, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("battery-temperature", new UoM(SIUnits.CELSIUS, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("battery-display", new UoM(Units.ONE, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("battery-soc", new UoM(Units.PERCENT, 1)),
+
+            // fault codes
+            new AbstractMap.SimpleEntry<String, UoM>("system-fault-0", new UoM(Units.ONE, 1)),
+            new AbstractMap.SimpleEntry<String, UoM>("system-fault-1", new UoM(Units.ONE, 1)),
+            new AbstractMap.SimpleEntry<String, UoM>("system-fault-2", new UoM(Units.ONE, 1)),
+            new AbstractMap.SimpleEntry<String, UoM>("system-fault-3", new UoM(Units.ONE, 1)),
+            new AbstractMap.SimpleEntry<String, UoM>("system-fault-4", new UoM(Units.ONE, 1)),
+            new AbstractMap.SimpleEntry<String, UoM>("system-fault-5", new UoM(Units.ONE, 1)),
+            new AbstractMap.SimpleEntry<String, UoM>("system-fault-6", new UoM(Units.ONE, 1)),
+            new AbstractMap.SimpleEntry<String, UoM>("system-fault-7", new UoM(Units.ONE, 1)),
+
+            // miscellaneous
+            new AbstractMap.SimpleEntry<String, UoM>("system-work-mode", new UoM(Units.ONE, 1)),
+            new AbstractMap.SimpleEntry<String, UoM>("sp-display-status", new UoM(Units.ONE, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("constant-power-ok", new UoM(Units.ONE, 1)),
+            new AbstractMap.SimpleEntry<String, UoM>("load-percent", new UoM(Units.PERCENT, 10)),
+
+            // reactive 'power' resp. 'energy'
+            new AbstractMap.SimpleEntry<String, UoM>("rac", new UoM(Units.VAR, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("erac-today", new UoM(Units.KILOVAR_HOUR, 10)),
+            new AbstractMap.SimpleEntry<String, UoM>("erac-total", new UoM(Units.KILOVAR_HOUR, 10))
+    //
+    );
+
+    public static Map<String, UoM> 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).
+ * <p>
+ * 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<DeviceType, String> 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<DeviceType, String> 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<List<GrowattDevice>>() {}.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<String> plantIds = new ArrayList<>();
+    private final Map<String, DeviceType> 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<HttpCookie> 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<String, JsonElement> doHttpRequest(HttpMethod method, String endPoint,
+            @Nullable Map<String, String> 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<String, JsonElement> doHttpRequestInner(HttpMethod method, String endPoint,
+            @Nullable Map<String, String> 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("<html>")) {
+            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<String, JsonElement> 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<String, String> 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<String, JsonElement> result = doHttpRequest(HttpMethod.GET, endPoint, params, null);
+
+        JsonElement obj = result.get("obj");
+        if (obj instanceof JsonObject object) {
+            Map<String, JsonElement> map = object.asMap();
+            Optional<String> 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<GrowattDevice> getPlantInfo(String plantId) throws GrowattApiException {
+        Map<String, String> 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<String, JsonElement> result = doHttpRequest(HttpMethod.GET, PLANT_INFO_API_ENDPOINT, params, null);
+
+        JsonElement deviceList = result.get("deviceList");
+        if (deviceList instanceof JsonArray deviceArray) {
+            try {
+                List<GrowattDevice> 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<String, String> params = new LinkedHashMap<>(); // keep params in order
+        params.put("userId", userId);
+
+        Map<String, JsonElement> 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<String, JsonElement> 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<String, JsonElement> 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<String, JsonElement> 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<String, JsonElement> 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.
+     * <p>
+     * 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<String, JsonElement> 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<ThingUID, Set<String>> 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<String> 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<ArrayList<GrottDevice>>() {}.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<GrowattPlant> data;
+    private @Nullable GrowattUser user;
+    private @Nullable Boolean success;
+
+    public List<GrowattPlant> getPlants() {
+        List<GrowattPlant> 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<Integer> {
+
+    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<String, QuantityType<?>> getChannelStates(GrottValues target)
+            throws NoSuchFieldException, SecurityException, IllegalAccessException, IllegalArgumentException {
+        Map<String, QuantityType<?>> 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<ThingTypeUID> 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<ThingUID> 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<String, GrottDevice> 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<Class<? extends ThingHandlerService>> 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<GrottDevice> 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<String, QuantityType<?>> 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<Channel> actualChannels = thing.getChannels();
+        List<Channel> 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<String> 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
+            + "<html>"
+            + "<body>"
+            + "<h1 style=\"font-family: Arial\">Growatt Binding Servlet</h1>"
+            + "<p>&nbsp;</p>"
+            + "<h3 style=\"font-family: Arial\">Status: <span style=\"color: #%s;\">%s</span></h3>"
+            + "</body>"
+            + "</html>";
+    // @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<GrowattBridgeHandler> 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 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<addon:addon id="growatt" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+	xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
+	xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
+
+	<type>binding</type>
+	<name>Growatt Binding</name>
+	<description>This is the binding for Growatt solar inverters.</description>
+	<connection>hybrid</connection>
+
+</addon:addon>
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 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="growatt"
+	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+	xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+	xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+	<!-- Bridge Thing Type -->
+	<bridge-type id="bridge">
+		<label>Growatt Bridge</label>
+		<description>Bridge Thing for Growatt Binding</description>
+
+		<config-description>
+			<parameter name="userName" type="text" required="false">
+				<label>User Name</label>
+				<description>User name to login to the Shine App.</description>
+				<advanced>true</advanced>
+			</parameter>
+			<parameter name="password" type="text" required="false">
+				<context>password</context>
+				<label>Password</label>
+				<description>Password to login to the Shine App.</description>
+				<advanced>true</advanced>
+			</parameter>
+		</config-description>
+	</bridge-type>
+
+	<!-- Inverter Thing Type -->
+	<thing-type id="inverter">
+		<supported-bridge-type-refs>
+			<bridge-type-ref id="bridge"/>
+		</supported-bridge-type-refs>
+
+		<label>Growatt Inverter</label>
+		<description>Inverter Thing for Growatt Binding</description>
+
+		<!-- All known channels; unused channels are dynamically deleted -->
+		<channels>
+			<channel id="system-status" typeId="system-status-code">
+				<label>Inverter Status</label>
+				<description>Status code of the inverter.</description>
+			</channel>
+
+			<!-- solar generation -->
+			<channel id="pv-power" typeId="system.electric-power">
+				<label>Solar Input Power</label>
+				<description>Power from solar panels.</description>
+			</channel>
+
+			<!-- electric data for strings #1 and #2 -->
+			<channel id="pv1-voltage" typeId="advanced-electric-voltage">
+				<label>String #1 Voltage</label>
+				<description>Voltage from solar panel string #1.</description>
+			</channel>
+			<channel id="pv2-voltage" typeId="advanced-electric-voltage">
+				<label>String #2 Voltage</label>
+				<description>Voltage from solar panel string #2.</description>
+			</channel>
+
+			<channel id="pv1-current" typeId="advanced-electric-current">
+				<label>String #1 Current</label>
+				<description>Current from solar panel string #1.</description>
+			</channel>
+			<channel id="pv2-current" typeId="advanced-electric-current">
+				<label>String #2 Current</label>
+				<description>Current from solar panel string #2.</description>
+			</channel>
+
+			<channel id="pv1-power" typeId="advanced-electric-power">
+				<label>String #1 Power</label>
+				<description>Power from solar panel string #1.</description>
+			</channel>
+			<channel id="pv2-power" typeId="advanced-electric-power">
+				<label>String #2 Power</label>
+				<description>Power from solar panel string #2.</description>
+			</channel>
+
+			<!-- grid electric data (1-phase resp. 3-phase) -->
+			<channel id="grid-frequency" typeId="advanced-electric-frequency">
+				<label>Grid Frequency</label>
+				<description>Frequency of the grid.</description>
+			</channel>
+			<channel id="grid-voltage-r" typeId="system.electric-voltage">
+				<label>Grid Voltage (#R)</label>
+				<description>Voltage of the grid (phase #R).</description>
+			</channel>
+			<channel id="grid-voltage-s" typeId="advanced-electric-voltage">
+				<label>Grid Voltage #S</label>
+				<description>Voltage of the grid phase #S.</description>
+			</channel>
+			<channel id="grid-voltage-t" typeId="advanced-electric-voltage">
+				<label>Grid Voltage #T</label>
+				<description>Voltage of the grid phase #T.</description>
+			</channel>
+			<channel id="grid-voltage-rs" typeId="advanced-electric-voltage">
+				<label>Grid Voltage #RS</label>
+				<description>Voltage of the grid phases #RS.</description>
+			</channel>
+			<channel id="grid-voltage-st" typeId="advanced-electric-voltage">
+				<label>Grid Voltage #ST</label>
+				<description>Voltage of the grid phases #ST.</description>
+			</channel>
+			<channel id="grid-voltage-tr" typeId="advanced-electric-voltage">
+				<label>Grid Voltage #TR</label>
+				<description>Voltage of the grid phases #TR.</description>
+			</channel>
+
+			<!-- inverter power to grid -->
+			<channel id="inverter-current-r" typeId="system.electric-current">
+				<label>Inverter Current (#R)</label>
+				<description>AC current from inverter (phase #R).</description>
+			</channel>
+			<channel id="inverter-current-s" typeId="advanced-electric-current">
+				<label>Inverter Current #S</label>
+				<description>AC current from inverter phase #S.</description>
+			</channel>
+			<channel id="inverter-current-t" typeId="advanced-electric-current">
+				<label>Inverter Current #T</label>
+				<description>AC current from inverter phase #T.</description>
+			</channel>
+
+			<channel id="inverter-power" typeId="system.electric-power">
+				<label>Inverter Power</label>
+				<description>AC power the inverter (total).</description>
+			</channel>
+			<channel id="inverter-power-r" typeId="system.electric-power">
+				<label>Inverter Power (#R)</label>
+				<description>AC power from inverter (phase #R).</description>
+			</channel>
+			<channel id="inverter-power-s" typeId="advanced-electric-power">
+				<label>Inverter Power #S</label>
+				<description>AC power from inverter phase #S.</description>
+			</channel>
+			<channel id="inverter-power-t" typeId="advanced-electric-power">
+				<label>Inverter Power #T</label>
+				<description>AC power from inverter phase #T.</description>
+			</channel>
+
+			<channel id="inverter-va" typeId="advanced-electric-va">
+				<label>Inverter VA</label>
+				<description>AC VA produced by inverter.</description>
+			</channel>
+
+			<!-- battery power -->
+			<channel id="charge-power" typeId="system.electric-power">
+				<label>Charge Power </label>
+				<description>Charge power to battery.</description>
+			</channel>
+			<channel id="charge-current" typeId="system.electric-current">
+				<label>Charge Current</label>
+				<description>Charge current to battery.</description>
+			</channel>
+			<channel id="discharge-power" typeId="system.electric-power">
+				<label>Discharge Power</label>
+				<description>Discharge power from battery.</description>
+			</channel>
+			<channel id="discharge-va" typeId="advanced-electric-va">
+				<label>Discharge VA</label>
+				<description>Discharge VA from battery.</description>
+			</channel>
+
+			<!-- power export to grid -->
+			<channel id="export-power" typeId="system.electric-power">
+				<label>Export Power</label>
+				<description>Power exported to grid.</description>
+			</channel>
+			<channel id="export-power-r" typeId="advanced-electric-power">
+				<label>Export Power #R</label>
+				<description>Power exported to grid phase #R.</description>
+			</channel>
+			<channel id="export-power-s" typeId="advanced-electric-power">
+				<label>Export Power #S</label>
+				<description>Power exported to grid phase #S.</description>
+			</channel>
+			<channel id="export-power-t" typeId="advanced-electric-power">
+				<label>Export Power #T</label>
+				<description>Power exported to grid phase #T.</description>
+			</channel>
+
+			<!-- power import from grid user -->
+			<channel id="import-power" typeId="system.electric-power">
+				<label>Import Power</label>
+				<description>Power imported.</description>
+			</channel>
+			<channel id="import-power-r" typeId="advanced-electric-power">
+				<label>Import Power #R</label>
+				<description>Power imported phase #R.</description>
+			</channel>
+			<channel id="import-power-s" typeId="advanced-electric-power">
+				<label>Import Power #S</label>
+				<description>Power imported phase #S.</description>
+			</channel>
+			<channel id="import-power-t" typeId="advanced-electric-power">
+				<label>Import Power #T</label>
+				<description>Power imported phase #T.</description>
+			</channel>
+
+			<!-- power to local load -->
+			<channel id="load-power" typeId="system.electric-power">
+				<label>Load Power</label>
+				<description>Power supplied to load.</description>
+			</channel>
+			<channel id="load-power-r" typeId="advanced-electric-power">
+				<label>Load Power #R</label>
+				<description>Power supplied to load phase #R.</description>
+			</channel>
+			<channel id="load-power-s" typeId="advanced-electric-power">
+				<label>Load Power #S</label>
+				<description>Power supplied to load phase #S.</description>
+			</channel>
+			<channel id="load-power-t" typeId="advanced-electric-power">
+				<label>Load Power #T</label>
+				<description>Power supplied to load phase #T.</description>
+			</channel>
+
+			<!-- inverter AC output energy -->
+			<channel id="inverter-energy-today" typeId="system.electric-energy">
+				<label>Inverter Energy Today</label>
+				<description>Inverter output energy produced today.</description>
+			</channel>
+			<channel id="inverter-energy-total" typeId="system.electric-energy">
+				<label>Inverter Energy Total</label>
+				<description>Total inverter output energy produced.</description>
+			</channel>
+
+			<!-- solar DC energy -->
+			<channel id="pv-energy-today" typeId="system.electric-energy">
+				<label>DC Energy Today</label>
+				<description>Solar DC energy collected.</description>
+			</channel>
+			<channel id="pv1-energy-today" typeId="advanced-electric-energy">
+				<label>DC Energy #1 Today</label>
+				<description>Solar DC energy collected by string #1 to grid today.</description>
+			</channel>
+			<channel id="pv2-energy-today" typeId="advanced-electric-energy">
+				<label>DC Energy #2 Today</label>
+				<description>Solar DC energy collected by string #2 to grid today.</description>
+			</channel>
+
+			<channel id="pv-energy-total" typeId="system.electric-energy">
+				<label>DC Energy Total</label>
+				<description>Total solar energy supplied to grid.</description>
+			</channel>
+			<channel id="pv1-energy-total" typeId="advanced-electric-energy">
+				<label>DC Energy #1 Total</label>
+				<description>Total solar DC collected by string #1.</description>
+			</channel>
+			<channel id="pv2-energy-total" typeId="advanced-electric-energy">
+				<label>DC Energy #2 Total</label>
+				<description>Total solar DC collected by string #2.</description>
+			</channel>
+
+			<!-- energy exported to grid -->
+			<channel id="export-energy-today" typeId="system.electric-energy">
+				<label>Export Energy Today</label>
+				<description>Energy exported to grid today.</description>
+			</channel>
+			<channel id="export-energy-total" typeId="system.electric-energy">
+				<label>Export Energy Total</label>
+				<description>Total energy exported to grid.</description>
+			</channel>
+
+			<!-- energy imported from grid -->
+			<channel id="import-energy-today" typeId="system.electric-energy">
+				<label>Import Energy Today</label>
+				<description>Energy imported from grid today.</description>
+			</channel>
+			<channel id="import-energy-total" typeId="system.electric-energy">
+				<label>Import Energy Total</label>
+				<description>Total energy imported from grid.</description>
+			</channel>
+
+			<!-- energy supplied to local -->
+			<channel id="load-energy-today" typeId="system.electric-energy">
+				<label>Load Energy Today</label>
+				<description>Energy supplied to load today.</description>
+			</channel>
+			<channel id="load-energy-total" typeId="system.electric-energy">
+				<label>Load Energy Total</label>
+				<description>Total energy supplied to load.</description>
+			</channel>
+
+			<!-- energy imported from grid to charge -->
+			<channel id="import-charge-energy-today" typeId="system.electric-energy">
+				<label>Battery Import Energy Today</label>
+				<description>Energy imported from grid to charge battery today.</description>
+			</channel>
+			<channel id="import-charge-energy-total" typeId="system.electric-energy">
+				<label>Battery Import Energy Totals</label>
+				<description>Total energy imported from grid to charge battery.</description>
+			</channel>
+
+			<!-- inverter energy to charge -->
+			<channel id="inverter-charge-energy-today" typeId="system.electric-energy">
+				<label>Battery Inverter Energy Today</label>
+				<description>Energy from inverter to charge battery today.</description>
+			</channel>
+			<channel id="inverter-charge-energy-total" typeId="system.electric-energy">
+				<label>Battery Inverter Energy Total</label>
+				<description>Total energy from inverter to charge battery.</description>
+			</channel>
+
+			<!-- energy consumed from battery -->
+			<channel id="discharge-energy-today" typeId="system.electric-energy">
+				<label>Battery Energy Today</label>
+				<description>Energy consumed from battery today.</description>
+			</channel>
+			<channel id="discharge-energy-total" typeId="system.electric-energy">
+				<label>Battery Energy Total</label>
+				<description>Total energy consumed from battery.</description>
+			</channel>
+
+			<!-- inverter up time -->
+			<channel id="total-work-time" typeId="advanced-work-time">
+				<label>Total Working Time</label>
+				<description>Total inverter working time.</description>
+			</channel>
+
+			<!-- bus voltages -->
+			<channel id="p-bus-voltage" typeId="advanced-electric-voltage">
+				<label>P Bus Voltage</label>
+				<description>P Bus voltage.</description>
+			</channel>
+			<channel id="n-bus-voltage" typeId="advanced-electric-voltage">
+				<label>N Bus Voltage</label>
+				<description>N Bus voltage.</description>
+			</channel>
+			<channel id="sp-bus-voltage" typeId="advanced-electric-voltage">
+				<label>SP Bus Voltage</label>
+				<description>SP Bus voltage.</description>
+			</channel>
+
+			<!-- temperatures -->
+			<channel id="pv-temperature" typeId="advanced-outdoor-temperature">
+				<label>Solar Panel Temperature</label>
+				<description>Temperature of the solar panels (string #1).</description>
+			</channel>
+			<channel id="pv-ipm-temperature" typeId="advanced-outdoor-temperature">
+				<label>Solar IPM Temperature</label>
+				<description>Temperature of the IPM.</description>
+			</channel>
+			<channel id="pv-boost-temperature" typeId="advanced-outdoor-temperature">
+				<label>Boost Temperature</label>
+				<description>Boost temperature.</description>
+			</channel>
+			<channel id="temperature-4" typeId="advanced-outdoor-temperature">
+				<label>Temperature #4</label>
+				<description>Temperature #4.</description>
+			</channel>
+			<channel id="pv2-temperature" typeId="advanced-outdoor-temperature">
+				<label>Solar Panel Temperature #2</label>
+				<description>Temperature of the solar panels (string #2).</description>
+			</channel>
+
+			<!-- battery data -->
+			<channel id="battery-type" typeId="advanced-status-code">
+				<label>Battery Type</label>
+				<description>Type code of the battery.</description>
+			</channel>
+			<channel id="battery-temperature" typeId="advanced-outdoor-temperature">
+				<label>Battery Temperature</label>
+				<description>Battery temperature.</description>
+			</channel>
+			<channel id="battery-voltage" typeId="advanced-electric-voltage">
+				<label>Battery Voltage</label>
+				<description>Battery voltage.</description>
+			</channel>
+			<channel id="battery-display" typeId="advanced-status-code">
+				<label>Battery Display</label>
+				<description>Battery display code.</description>
+			</channel>
+			<channel id="battery-soc" typeId="advanced-percent">
+				<label>Battery Charge</label>
+				<description>Battery state of charge.</description>
+			</channel>
+
+			<!-- fault codes -->
+			<channel id="system-fault-0" typeId="advanced-fault-code">
+				<label>Fault Code #0</label>
+				<description>System fault code #0.</description>
+			</channel>
+			<channel id="system-fault-1" typeId="advanced-fault-code">
+				<label>Fault Code #1</label>
+				<description>System fault code #1.</description>
+			</channel>
+			<channel id="system-fault-2" typeId="advanced-fault-code">
+				<label>Fault Code #2</label>
+				<description>System fault code #2.</description>
+			</channel>
+			<channel id="system-fault-3" typeId="advanced-fault-code">
+				<label>Fault Code #3</label>
+				<description>System fault code #3.</description>
+			</channel>
+			<channel id="system-fault-4" typeId="advanced-fault-code">
+				<label>Fault Code #4</label>
+				<description>System fault code #4.</description>
+			</channel>
+			<channel id="system-fault-5" typeId="advanced-fault-code">
+				<label>Fault Code #5</label>
+				<description>System fault code #5.</description>
+			</channel>
+			<channel id="system-fault-6" typeId="advanced-fault-code">
+				<label>Fault Code #6</label>
+				<description>System fault code #6.</description>
+			</channel>
+			<channel id="system-fault-7" typeId="advanced-fault-code">
+				<label>Fault Code #7</label>
+				<description>System fault code #7.</description>
+			</channel>
+
+			<!-- miscellaneous -->
+			<channel id="system-work-mode" typeId="advanced-status-code">
+				<label>System Work Mode</label>
+				<description>System work mode code.</description>
+			</channel>
+			<channel id="sp-display-status" typeId="advanced-status-code">
+				<label>Solar Panel Display</label>
+				<description>Solar panel display status code.</description>
+			</channel>
+			<channel id="constant-power-ok" typeId="advanced-status-code">
+				<label>Constant Power OK</label>
+				<description>Constant power OK code.</description>
+			</channel>
+			<channel id="load-percent" typeId="advanced-percent">
+				<label>Load Percent</label>
+				<description>Percent of full load.</description>
+			</channel>
+
+			<!-- reactive power resp. energy -->
+			<channel id="rac" typeId="advanced-electric-var">
+				<label>Reactive Power</label>
+				<description>Reactive power output.</description>
+			</channel>
+			<channel id="erac-today" typeId="advanced-electric-kvarh">
+				<label>Reactive Energy Today</label>
+				<description>Reactive energy supplied today.</description>
+			</channel>
+			<channel id="erac-total" typeId="advanced-electric-kvarh">
+				<label>Total Reactive Energy</label>
+				<description>Total reactive energy supplied.</description>
+			</channel>
+
+		</channels>
+
+		<config-description>
+			<parameter name="deviceId" type="text" required="true">
+				<label>Device Id</label>
+				<description>Id (serial number) of the inverter.</description>
+			</parameter>
+		</config-description>
+
+	</thing-type>
+
+	<!-- Binding Specific Channel Types -->
+	<channel-type id="system-status-code">
+		<item-type>Number:Dimensionless</item-type>
+		<label>Status Code</label>
+		<category>Status</category>
+		<state readOnly="true" pattern="%00d"/>
+	</channel-type>
+
+	<channel-type id="advanced-status-code" advanced="true">
+		<item-type>Number:Dimensionless</item-type>
+		<label>Status Code</label>
+		<category>Status</category>
+		<state readOnly="true" pattern="%00d"/>
+	</channel-type>
+
+	<channel-type id="advanced-fault-code" advanced="true">
+		<item-type>Number:Dimensionless</item-type>
+		<label>Fault Code</label>
+		<category>Siren</category>
+		<state readOnly="true" pattern="%00d"/>
+	</channel-type>
+
+	<channel-type id="advanced-percent" advanced="true">
+		<item-type>Number:Dimensionless</item-type>
+		<label>Percentage</label>
+		<state readOnly="true" pattern="%0.1f %%"/>
+	</channel-type>
+
+	<channel-type id="advanced-electric-frequency" advanced="true">
+		<item-type>Number:Frequency</item-type>
+		<label>Electric Frequency</label>
+		<category>Energy</category>
+		<state readOnly="true" pattern="%0.2f Hz"/>
+	</channel-type>
+
+	<channel-type id="advanced-electric-va" advanced="true">
+		<item-type>Number:Power</item-type>
+		<label>Electric VA</label>
+		<category>Energy</category>
+		<state readOnly="true" pattern="%0.0f VA"/>
+	</channel-type>
+
+	<channel-type id="advanced-work-time" advanced="true">
+		<item-type>Number:Time</item-type>
+		<label>Work Time</label>
+		<category>Time</category>
+		<state readOnly="true" pattern="%0.1f h"/>
+	</channel-type>
+
+	<channel-type id="advanced-electric-power" advanced="true">
+		<item-type>Number:Power</item-type>
+		<label>Electric Power</label>
+		<category>Energy</category>
+		<state readOnly="true" pattern="%0.0f W"/>
+	</channel-type>
+
+	<channel-type id="advanced-electric-current" advanced="true">
+		<item-type>Number:ElectricCurrent</item-type>
+		<label>Electric Current</label>
+		<category>Energy</category>
+		<state readOnly="true" pattern="%0.1f A"/>
+	</channel-type>
+
+	<channel-type id="advanced-electric-voltage" advanced="true">
+		<item-type>Number:ElectricPotential</item-type>
+		<label>Electric Voltage</label>
+		<category>Energy</category>
+		<state readOnly="true" pattern="%0.1f V"/>
+	</channel-type>
+
+	<channel-type id="advanced-electric-energy" advanced="true">
+		<item-type>Number:Energy</item-type>
+		<label>Electric Energy</label>
+		<category>Energy</category>
+		<state readOnly="true" pattern="%0.0f kWh"/>
+	</channel-type>
+
+	<channel-type id="advanced-electric-var" advanced="true">
+		<item-type>Number:Power</item-type>
+		<label>Electric Reactive Power</label>
+		<category>Energy</category>
+		<state readOnly="true" pattern="%0.0f var"/>
+	</channel-type>
+
+	<channel-type id="advanced-electric-kvarh" advanced="true">
+		<item-type>Number:Energy</item-type>
+		<label>Electric Reactive Energy</label>
+		<category>Energy</category>
+		<state readOnly="true" pattern="%0.0f kvarh"/>
+	</channel-type>
+
+	<channel-type id="advanced-outdoor-temperature" advanced="true">
+		<item-type>Number:Temperature</item-type>
+		<label>Outdoor Temperature</label>
+		<category>Temperature</category>
+		<state readOnly="true" pattern="%0.0f °C"/>
+	</channel-type>
+
+</thing:thing-descriptions>
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<String> 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<String> errors = new ArrayList<>();
+
+        for (Entry<String, UoM> 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<String, QuantityType<?>> 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<String> 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<String, JsonElement> 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<String, QuantityType<?>> 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<String, QuantityType<?>> 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 @@
     <module>org.openhab.binding.globalcache</module>
     <module>org.openhab.binding.gpstracker</module>
     <module>org.openhab.binding.gree</module>
-    <module>org.openhab.binding.groupepsa</module>
     <module>org.openhab.binding.groheondus</module>
+    <module>org.openhab.binding.groupepsa</module>
+    <module>org.openhab.binding.growatt</module>
     <module>org.openhab.binding.guntamatic</module>
     <module>org.openhab.binding.haassohnpelletstove</module>
     <module>org.openhab.binding.harmonyhub</module>