From 6f7b5b5f315f3ae0f1f3302d7c752f5b1fd28fcd Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green 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 --- 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 @@ org.openhab.binding.groupepsa ${project.version} + + org.openhab.addons.bundles + org.openhab.binding.growatt + ${project.version} + org.openhab.addons.bundles org.openhab.binding.guntamatic diff --git a/bundles/org.openhab.binding.growatt/NOTICE b/bundles/org.openhab.binding.growatt/NOTICE new file mode 100644 index 00000000000..38d625e3492 --- /dev/null +++ b/bundles/org.openhab.binding.growatt/NOTICE @@ -0,0 +1,13 @@ +This content is produced and maintained by the openHAB project. + +* Project home: https://www.openhab.org + +== Declared Project Licenses + +This program and the accompanying materials are made available under the terms +of the Eclipse Public License 2.0 which is available at +https://www.eclipse.org/legal/epl-2.0/. + +== Source Code + +https://github.com/openhab/openhab-addons diff --git a/bundles/org.openhab.binding.growatt/README.md b/bundles/org.openhab.binding.growatt/README.md new file mode 100644 index 00000000000..e960d787437 --- /dev/null +++ b/bundles/org.openhab.binding.growatt/README.md @@ -0,0 +1,349 @@ +# Growatt Binding + +This binding supports the integration of Growatt solar inverters. + +It depends on the independent [Grott](https://github.com/johanmeijer/grott#the-growatt-inverter-monitor) proxy server application. +This intercepts the logging data that the Growatt inverter data logger normally sends directly to the Growatt cloud server. +It sends the original (encoded) data onwards to the cloud server (so the cloud server will not notice anything different). +But it also sends a (decoded) copy to openHAB as well. + +## Supported Things + +The binding supports two types of things: + +- `bridge`: The bridge is the interface to the Grott application; it receives the data from all inverters. +- `inverter`: The inverter thing contains channels which are updated with solor production and consumption data. + +## Discovery + +There is no automatic discovery of the bridge. +However if a bridge exists and it receives inverter data, then a matching inverter thing is created in the Inbox. + +## Thing Configuration + +The `bridge` thing allows configuration of the user credentials, which are only required if you want to send inverter commands via the Growatt cloud server: + +| Name | Type | Description | Advanced |Required | +|-----------|---------|------------------------------------------------------------------------------------------|----------|---------| +| userName | text | User name for the Growatt Shine app. Only needed if using [Rule Actions](#rule-actions) | yes | no | +| password | text | Password for the Growatt Shine app. Only needed if using [Rule Actions](#rule-actions) | yes | no | + +The `inverter` thing requires configuration of its serial number resp. `deviceId`: + +| Name | Type | Description | Required | +|-----------|---------|------------------------------------------------------------------------------------------|----------| +| deviceId | text | Device serial number or id as configured in the Growatt cloud and the Grott application. | yes | + +## Channels + +The `bridge` thing has no channels. + +The `inverter` thing supports many possible channels relating to solar generation and consumption. +All channels are read-only. +Depending on the inverter model, and its configuration, not all of the channels will be present. +The list of all possible channels is as follows: + +| Channel | Type | Description | Advanced | +|-------------------------------|---------------------------|------------------------------------------------------|----------| +| system-status | Number:Dimensionless | Inverter status code. | | +| pv1-voltage | Number:ElectricPotential | DC voltage from solar panel string #1. | yes | +| pv2-voltage | Number:ElectricPotential | DC voltage from solar panel string #2. | yes | +| pv1-current | Number:ElectricCurrent | DC current from solar panel string #1. | yes | +| pv2-current | Number:ElectricCurrent | DC current from solar panel string #2. | yes | +| pv-power | Number:Power | Total DC solar input power. | | +| pv1-power | Number:Power | DC power from solar panel string #1. | yes | +| pv2-power | Number:Power | DC power from solar panel string #2. | yes | +| grid-frequency | Number:Frequency | Frequency of the grid. | yes | +| grid-voltage-r | Number:ElectricPotential | Voltage of the grid (phase #R). | | +| grid-voltage-s | Number:ElectricPotential | Voltage of the grid phase #S. | yes | +| grid-voltage-t | Number:ElectricPotential | Voltage of the grid phase #T. | yes | +| grid-voltage-rs | Number:ElectricPotential | Voltage of the grid phases #RS. | yes | +| grid-voltage-st | Number:ElectricPotential | Voltage of the grid phases #ST. | yes | +| grid-voltage-tr | Number:ElectricPotential | Voltage of the grid phases #TR. | yes | +| inverter-current-r | Number:ElectricCurrent | AC current from inverter (phase #R). | yes | +| inverter-current-s | Number:ElectricCurrent | AC current from inverter phase #S. | yes | +| inverter-current-t | Number:ElectricCurrent | AC current from inverter phase #T. | yes | +| inverter-power | Number:Power | Total AC output power from inverter. | | +| inverter-power-r | Number:Power | AC power from inverter (phase #R). | | +| inverter-power-s | Number:Power | AC power from inverter phase #S. | yes | +| inverter-power-t | Number:Power | AC power from inverter phase #T. | yes | +| inverter-va | Number:Power | AC VA from inverter. | yes | +| export-power | Number:Power | Power exported to grid. | | +| export-power-r | Number:Power | Power exported to grid phase #R. | yes | +| export-power-s | Number:Power | Power exported to grid phase #S. | yes | +| export-power-t | Number:Power | Power exported to grid phase #T. | yes | +| import-power | Number:Power | Power imported from grid. | | +| import-power-r | Number:Power | Power imported from grid phase #R. | yes | +| import-power-s | Number:Power | Power imported from grid phase #S. | yes | +| import-power-t | Number:Power | Power imported from grid phase #T. | yes | +| load-power | Number:Power | Power supplied to load. | | +| load-power-r | Number:Power | Power supplied to load phase #R. | yes | +| load-power-s | Number:Power | Power supplied to load phase #S. | yes | +| load-power-t | Number:Power | Power supplied to load phase #T. | yes | +| charge-power | Number:Power | Battery charge power. | | +| charge-current | Number:ElectricCurrent | Battery charge current. | yes | +| discharge-power | Number:Power | Battery discharge power. | | +| discharge-va | Number:Power | Battery discharge VA. | yes | +| pv-energy-today | Number:Energy | DC energy collected by solar panels today. | | +| pv1-energy-today | Number:Energy | DC energy collected by solar panels string #1 today. | yes | +| pv2-energy-today | Number:Energy | DC energy collected by solar panels string #2 today. | yes | +| pv-energy-total | Number:Energy | Total DC energy collected by solar panels. | | +| pv1-energy-total | Number:Energy | Total DC energy collected by solar panels string #1. | yes | +| pv2-energy-total | Number:Energy | Total DC energy collected by solar panels string #2. | yes | +| inverter-energy-today | Number:Energy | AC energy produced by inverter today. | | +| inverter-energy-total | Number:Energy | Total AC energy produced by inverter. | | +| export-energy-today | Number:Energy | Energy exported today. | | +| export-energy-total | Number:Energy | Total energy exported. | | +| import-energy-today | Number:Energy | Energy imported today. | | +| import-energy-total | Number:Energy | Total energy imported. | | +| load-energy-today | Number:Energy | Energy supplied to load today. | | +| load-energy-total | Number:Energy | Total energy supplied to load. | | +| import-charge-energy-today | Number:Energy | Energy imported to charge battery today. | | +| import-charge-energy-total | Number:Energy | Total energy imported to charge battery. | | +| inverter-charge-energy-today | Number:Energy | Inverter energy to charge battery today. | | +| inverter-charge-energy-total | Number:Energy | Total inverter energy to charge battery. | | +| discharge-energy-today | Number:Energy | Energy consumed from battery. | | +| discharge-energy-total | Number:Energy | Total energy consumed from battery. | | +| total-work-time | Number:Time | Total work time of the system. | yes | +| p-bus-voltage | Number:ElectricPotential | P Bus voltage. | yes | +| n-bus-voltage | Number:ElectricPotential | N Bus voltage. | yes | +| sp-bus-voltage | Number:ElectricPotential | SP Bus voltage. | yes | +| pv-temperature | Number:Temperature | Temperature of the solar panels (string #1). | yes | +| pv-ipm-temperature | Number:Temperature | Temperature of the IPM. | yes | +| pv-boost-temperature | Number:Temperature | Boost temperature. | yes | +| temperature-4 | Number:Temperature | Temperature #4. | yes | +| pv2-temperature | Number:Temperature | Temperature of the solar panels (string #2). | yes | +| battery-type | Number:Dimensionless | Type code of the battery. | yes | +| battery-temperature | Number:Temperature | Battery temperature. | yes | +| battery-voltage | Number:ElectricPotential | Battery voltage. | yes | +| battery-display | Number:Dimensionless | Battery display code. | yes | +| battery-soc | Number:Dimensionless | Battery State of Charge percent. | yes | +| system-fault-0 | Number:Dimensionless | System fault code #0. | yes | +| system-fault-1 | Number:Dimensionless | System fault code #1. | yes | +| system-fault-2 | Number:Dimensionless | System fault code #2. | yes | +| system-fault-3 | Number:Dimensionless | System fault code #3. | yes | +| system-fault-4 | Number:Dimensionless | System fault code #4. | yes | +| system-fault-5 | Number:Dimensionless | System fault code #5. | yes | +| system-fault-6 | Number:Dimensionless | System fault code #6. | yes | +| system-fault-7 | Number:Dimensionless | System fault code #7. | yes | +| system-work-mode | Number:Dimensionless | System work mode code. | yes | +| sp-display-status | Number:Dimensionless | Solar panel display status code. | yes | +| constant-power-ok | Number:Dimensionless | Constant power OK code. | yes | +| load-percent | Number:Dimensionless | Percent of full load. | yes | +| rac | Number:Power | Reactive 'power' (var). | yes | +| erac-today | Number:Energy | Reactive 'energy' today (kvarh). | yes | +| erac-total | Number:Energy | Total reactive 'energy' (kvarh). | yes | + +## Rule Actions + +This binding includes rule actions, which allow you to setup programs for battery charging and discharging. +Each inverter thing has a separate actions instance, which can be retrieved as follows. + +```php +val growattActions = getActions("growatt", "growatt:inverter:home:sph") +``` + +Where the first parameter must always be `growatt` and the second must be the full inverter thing UID. +Once the action instance has been retrieved, you can invoke the following method: + +```php +growattActions.setupBatteryProgram(int programMode, @Nullable Integer powerLevel, @Nullable Integer stopSOC, @Nullable Boolean enableAcCharging, @Nullable String startTime, @Nullable String stopTime, @Nullable Boolean enableProgram) +``` + +The meaning of the method parameters is as follows: + +| Parameter | Description | +|-------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------| +| programMode | The program mode to set i.e. 'Load First' (0), 'Battery First' (1), 'Grid First' (2). | +| powerLevel2) | The percentage rate of battery (dis-)charge e.g. 100 - in 'Battery First' mode => charge power, otherwise => discharge power. | +| stopSOC2) | The battery SOC (state of charge) percentage when the program shall stop e.g. 20 - in 'Battery First' mode => max. SOC, otherwise => min. SOC. | +| enableAcCharging2) | Allow the battery to be charged from the AC mains supply e.g. true, false. | +| startTime1,2) | String representation of the local time when the program `time segment` shall start e.g. "00:15" | +| stopTime1,2) | String representation of the local time when the program `time segment` shall stop e.g. "06:45" | +| enableProgram1,2) | Enable / disable the program `time segment` e.g. true, false | + +Notes: + +-1) ***WARNING*** inverters have different program `time segment`'s for each `programMode`. +To prevent unexpected results do not overlap the `time segment`'s. + +-2) Depending on inverter type and `programMode` certain parameters may accept 'null' values. +The 'mix', 'sph' and 'spa' types set the battery program in a single command, so all parameters - except `enableAcCharging` - **must** be ***non-***'null'. +By contrast 'tlx' types set the battery program in up to four partial commands, and you may pass 'null' parameters in order to omit a partial command. +The permission for passing 'null' parameters, and the effect of such 'null' parameters, is shown in detail in the table below: + +| Parameter | Permission for.. / effect of.. passing a 'null' parameter | +|------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------| +| programMode | Shall **not** be 'null' under any circumstance! | +| powerLevel | May be 'null' on 'tlx' inverters whereby the prior `programMode` / `powerLevel` continues to apply. | +| stopSOC | May be 'null' on 'tlx' inverters whereby the prior `programMode` / `stopSOC` continues to apply. | +| enableAcCharging | If 'null' the prior `enableAcCharging` (if any) continues to apply. Shall **not** be 'null' on 'mix' inverter 'Battery First' program. | +| startTime, stopTime, enableProgram | May be 'null' on 'tlx' inverters whereby the prior `programMode` / `time segment` continues to apply - note all 'null' resp. non-'null'. | + +Example: + +```php +rule "Setup Solar Battery Charging Program" +when + Time cron "0 10 0 ? * * *" +then + val growattActions = getActions("growatt", "growatt:inverter:home:ABCD1234") // thing UID + if (growattActions === null) { + logWarn("Rules", "growattActions is null") + } else { + + // fixed algorithm parameters + val Integer programMode = 1 // 0 = Load First, 1 = Battery First, 2 = Grid First + val Integer powerLevel = 23 // percent + val Boolean enableAcCharging = true + val String startTime = "00:20" + val String stopTime = "07:00" + val Boolean enableProgram = true + + // calculation intermediaries + val batteryFull = 6500.0 // Wh + val batteryMin = 500.0 // Wh + val daylightConsumption = 10000.0 // Wh + val maximumSOC = 100.0 // percent + val minimumSOC = 20.0 // percent + + + // calculate stop SOC based on weather forecast + val Double solarForecast = (ForecastSolar_PV_Whole_Site_Forecast_Today.state as QuantityType).toUnit("Wh").doubleValue() + var Double targetSOC = (100.0 * (batteryMin + daylightConsumption - solarForecast)) / batteryFull + if (targetSOC > maximumSOC) { + targetSOC = maximumSOC + } else if (targetSOC < minimumSOC) { + targetSOC = minimumSOC + } + + // convert to integer + val Integer stopSOC = targetSOC.intValue() // percent + + logInfo("Rules", "Setup Charging Program:{solarForecast:" + solarForecast + "Wh, programMode:" + programMode + ", powerLevel:" + powerLevel + "%, stopSOC:" + stopSOC + "%, enableCharging:" + enableAcCharging + ", startTime:" + startTime + ", stopTime:" + stopTime + ", enableProgram:" + enableProgram +"}") + growattActions.setupBatteryProgram(programMode, powerLevel, stopSOC, enableAcCharging, startTime, stopTime, enableProgram) + } +end +``` + +## Full Example + +### Example `.things` file + +```java +Bridge growatt:bridge:home "Growattt Bridge" [userName="USERNAME", password="PASSWORD"] { + Thing inverter sph "Growatt SPH Inverter" [deviceId="INVERTERTID"] +} +``` + +### Example `.items` file + +```java +Number:ElectricPotential Solar_String1_Voltage "Solar String #1 PV Voltage" {channel="growatt:inverter:home:sph:pv1-voltage"} +Number:ElectricCurrent Solar_String1_Current "Solar String #1 PV Current" {channel="growatt:inverter:home:sph:pv1-current"} +Number:Power Solar_String1_Power "Solar String #1 PV Power" {channel="growatt:inverter:home:sph:pv1-power"} +Number:Energy Solar_Output_Energy "Solar Output Energy Total" {channel="growatt:inverter:home:sph:pv-energy-total"} +``` + +Example using a transform profile to invert an item value: + +```java +// charge item with positive value +Number:Power Charge_Power "Charge Power [%.0f W]" {channel="growatt:inverter:home:sph:charge-power"} + +// discarge item with negative value +Number:Power Discharge_Power "Discharge Power [%.0f W]" {channel="growatt:inverter:home:sph:discharge-power" [ profile="transform:JS", toItemScript="| Quantity(input).multiply(-1).toString();" ] } +``` + +## Grott Application Installation and Setup + +You can install the Grott application either on the same computer as openHAB or on another. +The following assumes you will be running it on the same computer. +The Grott application acts as a proxy server between your Growatt inverter and the Growatt cloud server. +It intercepts data packets between the inverter and the cloud server, and it sends a copy of the intercepted data also to openHAB. + +**NOTE**: make sure that the Grott application is **FULLY OPERATIONAL** for your inverter **BEFORE** you create any things in openHAB! +Otherwise the binding might create a wrong (or even empty) list of channels for the inverter thing. +(Yet if you do make that mistake you can rectify it by deleting and recreating the thing). + +You should configure the Grott application via its `grott.ini` file. +Configure Grott to match your inverter according to the [instructions](https://github.com/johanmeijer/grott#the-growatt-inverter-monitor). + +### Install Python + +If Python is not already installed on you computer, then install it first. +And install the following additional necessary python packages: + +```bash +sudo pip3 install paho-mqtt +sudo pip3 install requests +``` + +### Install Grott + +First install the Grott application and the Grott application extension files in a Grott specific home folder. +Note that Grott requires the `grottext.py` application extension in addition to the standard application files. +The installation is as follows: + +- Create a 'home' sub-folder for Grott e.g. `/home//grott/`. +- Copy `grott.py`, `grottconf.py`, `grottdata.py`, `grottproxy.py`, `grottsniffer.py`, `grottserver.py` to the home folder. +- Copy `grottext.py` application extension to the home folder. +- Copy `grott.ini` configuration file to the home folder. +- Modify `grott.ini` to run in proxy mode; not in compatibility mode; show your inverter type; not run MQTT; not run PVOutput; enable the `grottext` extension; and set the openHAB `/growatt` servlet url. + +A suggested Grott configuration for openHAB is as follows: + +```php +[Generic] +mode = proxy +compat = False +invtype = sph // your inverter type + +[MQTT] +nomqtt = True // disable mqtt + +[PVOutput] +pvoutput = False // disable pvoutput + +[extension] // enable the 'grottext' extension +extension = True +extname = grottext +extvar = {"url": "http://127.0.0.1:8080/growatt"} // or ip address of openHAB (if remote) +``` + +### Start Grott as a Service + +Finally you should set your computer to starts the Grott application automatically as a service when your computer starts. +For Windows see wiki: https://github.com/johanmeijer/grott/wiki/Grott-as-a-service-(Windows) +For Linux see wiki: https://github.com/johanmeijer/grott/wiki/Grott-as-a-service-(Linux) +The service configuration for Linux is summarised below: + +- Copy the `grott.service` file to the `/etc/systemd/system/` folder +- Modify `grott.service` to enter your user name; the Grott settings; the path to Python; and the path to the Grott application: + +```php +[Service] +SyslogIdentifier=grott +User= // your username +WorkingDirectory=/home//grott/ // your home grott folder +ExecStart=-/usr/bin/python3 -u /home//grott/grott.py -v // ditto +``` + +And finally enable the Grott service: + +```bash +sudo systemctl enable grott +``` + +### Route Growatt Inverter Logging via Grott Proxy + +Normally the Growatt inverter sends its logging data directly to port `5279` on the Growatt server at `server.growatt.com` (ip=47.91.67.66) on the cloud. +Grott is a proxy server that interposes itself beween the inverter and the cloud server. +i.e. it receives the inverter logging data and forwards it unchanged to the cloud server. + +**WARNING**: make sure that Grott is running on a computer with a **STATIC IP ADDRESS** (and note this safely)! +Otherwise if the computer changes its ip address dynamically, it can no longer intercept the inverter data. +This means **YOU WILL NO LONGER BE ABLE TO RESET THE INVERTER** to its original settings! + +You need to use the Growatt App to tell the inverter to send its logging data to the Grott proxy instead of to the cloud. +See wiki: https://github.com/johanmeijer/grott/wiki/Rerouting-Growatt-Wifi-TCPIP-data-via-your-Grott-Server for more information. diff --git a/bundles/org.openhab.binding.growatt/pom.xml b/bundles/org.openhab.binding.growatt/pom.xml new file mode 100644 index 00000000000..649edba4841 --- /dev/null +++ b/bundles/org.openhab.binding.growatt/pom.xml @@ -0,0 +1,17 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 4.2.0-SNAPSHOT + + + org.openhab.binding.growatt + + openHAB Add-ons :: Bundles :: Growatt Binding + + diff --git a/bundles/org.openhab.binding.growatt/src/main/feature/feature.xml b/bundles/org.openhab.binding.growatt/src/main/feature/feature.xml new file mode 100644 index 00000000000..735964b4dbc --- /dev/null +++ b/bundles/org.openhab.binding.growatt/src/main/feature/feature.xml @@ -0,0 +1,9 @@ + + + mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features + + + openhab-runtime-base + mvn:org.openhab.addons.bundles/org.openhab.binding.growatt/${project.version} + + diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/GrowattBindingConstants.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/GrowattBindingConstants.java new file mode 100644 index 00000000000..9fc85488006 --- /dev/null +++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/GrowattBindingConstants.java @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.growatt.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.thing.ThingTypeUID; + +/** + * The {@link GrowattBindingConstants} class defines common constants, which are + * used across the whole binding. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class GrowattBindingConstants { + + public static final String BINDING_ID = "growatt"; + + public static final ThingTypeUID THING_TYPE_BRIDGE = new ThingTypeUID(BINDING_ID, "bridge"); + public static final ThingTypeUID THING_TYPE_INVERTER = new ThingTypeUID(BINDING_ID, "inverter"); +} diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/GrowattChannels.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/GrowattChannels.java new file mode 100644 index 00000000000..503fd1ccd46 --- /dev/null +++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/GrowattChannels.java @@ -0,0 +1,196 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.growatt.internal; + +import java.util.AbstractMap; +import java.util.Map; + +import javax.measure.Unit; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.library.unit.SIUnits; +import org.openhab.core.library.unit.Units; + +/** + * The {@link GrowattChannels} class defines the channel ids and respective UoM and scaling factors. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class GrowattChannels { + + /** + * Class encapsulating units of measure and scale information. + */ + public static class UoM { + public final Unit units; + public final float divisor; + + public UoM(Unit units, float divisor) { + this.units = units; + this.divisor = divisor; + } + } + + /** + * Map of the channel ids to their respective UoM and scaling factors + */ + private static final Map CHANNEL_ID_UOM_MAP = Map.ofEntries( + // inverter state + new AbstractMap.SimpleEntry("system-status", new UoM(Units.ONE, 1)), + + // solar generation + new AbstractMap.SimpleEntry("pv-power", new UoM(Units.WATT, 10)), + + // electric data for strings #1 and #2 + new AbstractMap.SimpleEntry("pv1-voltage", new UoM(Units.VOLT, 10)), + new AbstractMap.SimpleEntry("pv1-current", new UoM(Units.AMPERE, 10)), + new AbstractMap.SimpleEntry("pv1-power", new UoM(Units.WATT, 10)), + + new AbstractMap.SimpleEntry("pv2-voltage", new UoM(Units.VOLT, 10)), + new AbstractMap.SimpleEntry("pv2-current", new UoM(Units.AMPERE, 10)), + new AbstractMap.SimpleEntry("pv2-power", new UoM(Units.WATT, 10)), + + // grid electric data (1-phase resp. 3-phase) + new AbstractMap.SimpleEntry("grid-frequency", new UoM(Units.HERTZ, 100)), + + new AbstractMap.SimpleEntry("grid-voltage-r", new UoM(Units.VOLT, 10)), + new AbstractMap.SimpleEntry("grid-voltage-s", new UoM(Units.VOLT, 10)), + new AbstractMap.SimpleEntry("grid-voltage-t", new UoM(Units.VOLT, 10)), + new AbstractMap.SimpleEntry("grid-voltage-rs", new UoM(Units.VOLT, 10)), + new AbstractMap.SimpleEntry("grid-voltage-st", new UoM(Units.VOLT, 10)), + new AbstractMap.SimpleEntry("grid-voltage-tr", new UoM(Units.VOLT, 10)), + + // inverter output + new AbstractMap.SimpleEntry("inverter-current-r", new UoM(Units.AMPERE, 10)), + new AbstractMap.SimpleEntry("inverter-current-s", new UoM(Units.AMPERE, 10)), + new AbstractMap.SimpleEntry("inverter-current-t", new UoM(Units.AMPERE, 10)), + + new AbstractMap.SimpleEntry("inverter-power", new UoM(Units.WATT, 10)), + new AbstractMap.SimpleEntry("inverter-power-r", new UoM(Units.WATT, 10)), + new AbstractMap.SimpleEntry("inverter-power-s", new UoM(Units.WATT, 10)), + new AbstractMap.SimpleEntry("inverter-power-t", new UoM(Units.WATT, 10)), + + new AbstractMap.SimpleEntry("inverter-va", new UoM(Units.VOLT_AMPERE, 10)), + + // battery discharge / charge power + new AbstractMap.SimpleEntry("charge-current", new UoM(Units.AMPERE, 10)), + new AbstractMap.SimpleEntry("charge-power", new UoM(Units.WATT, 10)), + + new AbstractMap.SimpleEntry("discharge-power", new UoM(Units.WATT, 10)), + new AbstractMap.SimpleEntry("discharge-va", new UoM(Units.VOLT_AMPERE, 10)), + + // export power to grid + new AbstractMap.SimpleEntry("export-power", new UoM(Units.WATT, 10)), + new AbstractMap.SimpleEntry("export-power-r", new UoM(Units.WATT, 10)), + new AbstractMap.SimpleEntry("export-power-s", new UoM(Units.WATT, 10)), + new AbstractMap.SimpleEntry("export-power-t", new UoM(Units.WATT, 10)), + + // power to user + new AbstractMap.SimpleEntry("import-power", new UoM(Units.WATT, 10)), + new AbstractMap.SimpleEntry("import-power-r", new UoM(Units.WATT, 10)), + new AbstractMap.SimpleEntry("import-power-s", new UoM(Units.WATT, 10)), + new AbstractMap.SimpleEntry("import-power-t", new UoM(Units.WATT, 10)), + + // power to local + new AbstractMap.SimpleEntry("load-power", new UoM(Units.WATT, 10)), + new AbstractMap.SimpleEntry("load-power-r", new UoM(Units.WATT, 10)), + new AbstractMap.SimpleEntry("load-power-s", new UoM(Units.WATT, 10)), + new AbstractMap.SimpleEntry("load-power-t", new UoM(Units.WATT, 10)), + + // inverter output energy + new AbstractMap.SimpleEntry("inverter-energy-today", new UoM(Units.KILOWATT_HOUR, 10)), + new AbstractMap.SimpleEntry("inverter-energy-total", new UoM(Units.KILOWATT_HOUR, 10)), + + // solar DC input energy + new AbstractMap.SimpleEntry("pv-energy-today", new UoM(Units.KILOWATT_HOUR, 10)), + new AbstractMap.SimpleEntry("pv1-energy-today", new UoM(Units.KILOWATT_HOUR, 10)), + new AbstractMap.SimpleEntry("pv2-energy-today", new UoM(Units.KILOWATT_HOUR, 10)), + + new AbstractMap.SimpleEntry("pv-energy-total", new UoM(Units.KILOWATT_HOUR, 10)), + new AbstractMap.SimpleEntry("pv1-energy-total", new UoM(Units.KILOWATT_HOUR, 10)), + new AbstractMap.SimpleEntry("pv2-energy-total", new UoM(Units.KILOWATT_HOUR, 10)), + + // energy exported to grid + new AbstractMap.SimpleEntry("export-energy-today", new UoM(Units.KILOWATT_HOUR, 10)), + new AbstractMap.SimpleEntry("export-energy-total", new UoM(Units.KILOWATT_HOUR, 10)), + + // energy imported from grid + new AbstractMap.SimpleEntry("import-energy-today", new UoM(Units.KILOWATT_HOUR, 10)), + new AbstractMap.SimpleEntry("import-energy-total", new UoM(Units.KILOWATT_HOUR, 10)), + + // energy supplied to load + new AbstractMap.SimpleEntry("load-energy-today", new UoM(Units.KILOWATT_HOUR, 10)), + new AbstractMap.SimpleEntry("load-energy-total", new UoM(Units.KILOWATT_HOUR, 10)), + + // energy imported to charge + new AbstractMap.SimpleEntry("import-charge-energy-today", new UoM(Units.KILOWATT_HOUR, 10)), + new AbstractMap.SimpleEntry("import-charge-energy-total", new UoM(Units.KILOWATT_HOUR, 10)), + + // inverter energy to charge + new AbstractMap.SimpleEntry("inverter-charge-energy-today", new UoM(Units.KILOWATT_HOUR, 10)), + new AbstractMap.SimpleEntry("inverter-charge-energy-total", new UoM(Units.KILOWATT_HOUR, 10)), + + // energy supplied from discharge + new AbstractMap.SimpleEntry("discharge-energy-today", new UoM(Units.KILOWATT_HOUR, 10)), + new AbstractMap.SimpleEntry("discharge-energy-total", new UoM(Units.KILOWATT_HOUR, 10)), + + // inverter up time + new AbstractMap.SimpleEntry("total-work-time", new UoM(Units.HOUR, 7200)), + + // bus voltages + new AbstractMap.SimpleEntry("p-bus-voltage", new UoM(Units.VOLT, 10)), + new AbstractMap.SimpleEntry("n-bus-voltage", new UoM(Units.VOLT, 10)), + new AbstractMap.SimpleEntry("sp-bus-voltage", new UoM(Units.VOLT, 10)), + + // temperatures + new AbstractMap.SimpleEntry("pv-temperature", new UoM(SIUnits.CELSIUS, 10)), + new AbstractMap.SimpleEntry("pv-ipm-temperature", new UoM(SIUnits.CELSIUS, 10)), + new AbstractMap.SimpleEntry("pv-boost-temperature", new UoM(SIUnits.CELSIUS, 10)), + new AbstractMap.SimpleEntry("temperature-4", new UoM(SIUnits.CELSIUS, 10)), + new AbstractMap.SimpleEntry("pv2-temperature", new UoM(SIUnits.CELSIUS, 10)), + + // battery data + new AbstractMap.SimpleEntry("battery-type", new UoM(Units.ONE, 1)), + new AbstractMap.SimpleEntry("battery-voltage", new UoM(Units.VOLT, 10)), + new AbstractMap.SimpleEntry("battery-temperature", new UoM(SIUnits.CELSIUS, 10)), + new AbstractMap.SimpleEntry("battery-display", new UoM(Units.ONE, 10)), + new AbstractMap.SimpleEntry("battery-soc", new UoM(Units.PERCENT, 1)), + + // fault codes + new AbstractMap.SimpleEntry("system-fault-0", new UoM(Units.ONE, 1)), + new AbstractMap.SimpleEntry("system-fault-1", new UoM(Units.ONE, 1)), + new AbstractMap.SimpleEntry("system-fault-2", new UoM(Units.ONE, 1)), + new AbstractMap.SimpleEntry("system-fault-3", new UoM(Units.ONE, 1)), + new AbstractMap.SimpleEntry("system-fault-4", new UoM(Units.ONE, 1)), + new AbstractMap.SimpleEntry("system-fault-5", new UoM(Units.ONE, 1)), + new AbstractMap.SimpleEntry("system-fault-6", new UoM(Units.ONE, 1)), + new AbstractMap.SimpleEntry("system-fault-7", new UoM(Units.ONE, 1)), + + // miscellaneous + new AbstractMap.SimpleEntry("system-work-mode", new UoM(Units.ONE, 1)), + new AbstractMap.SimpleEntry("sp-display-status", new UoM(Units.ONE, 10)), + new AbstractMap.SimpleEntry("constant-power-ok", new UoM(Units.ONE, 1)), + new AbstractMap.SimpleEntry("load-percent", new UoM(Units.PERCENT, 10)), + + // reactive 'power' resp. 'energy' + new AbstractMap.SimpleEntry("rac", new UoM(Units.VAR, 10)), + new AbstractMap.SimpleEntry("erac-today", new UoM(Units.KILOVAR_HOUR, 10)), + new AbstractMap.SimpleEntry("erac-total", new UoM(Units.KILOVAR_HOUR, 10)) + // + ); + + public static Map getMap() { + return GrowattChannels.CHANNEL_ID_UOM_MAP; + } +} diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/action/GrowattActions.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/action/GrowattActions.java new file mode 100644 index 00000000000..bd55c04a079 --- /dev/null +++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/action/GrowattActions.java @@ -0,0 +1,76 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.growatt.internal.action; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.growatt.internal.handler.GrowattInverterHandler; +import org.openhab.core.automation.annotation.ActionInput; +import org.openhab.core.automation.annotation.RuleAction; +import org.openhab.core.thing.binding.ThingActions; +import org.openhab.core.thing.binding.ThingActionsScope; +import org.openhab.core.thing.binding.ThingHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Implementation of the {@link ThingActions} interface used for setting up battery charging and discharging programs. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@ThingActionsScope(name = "growatt") +@NonNullByDefault +public class GrowattActions implements ThingActions { + + private final Logger logger = LoggerFactory.getLogger(GrowattActions.class); + private @Nullable GrowattInverterHandler handler; + + public static void setupBatteryProgram(ThingActions actions, Integer programMode, @Nullable Integer powerLevel, + @Nullable Integer stopSOC, @Nullable Boolean enableAcCharging, @Nullable String startTime, + @Nullable String stopTime, @Nullable Boolean enableProgram) { + if (actions instanceof GrowattActions growattActions) { + growattActions.setupBatteryProgram(programMode, powerLevel, stopSOC, enableAcCharging, startTime, stopTime, + enableProgram); + } else { + throw new IllegalArgumentException("The 'actions' argument is not an instance of GrowattActions"); + } + } + + @Override + public @Nullable ThingHandler getThingHandler() { + return handler; + } + + @Override + public void setThingHandler(@Nullable ThingHandler handler) { + this.handler = (handler instanceof GrowattInverterHandler growattHandler) ? growattHandler : null; + } + + @RuleAction(label = "@text/actions.battery-program.label", description = "@text/actions.battery-program.description") + public void setupBatteryProgram( + @ActionInput(name = "program-mode", label = "@text/actions.program-mode.label", description = "@text/actions.program-mode.description") Integer programMode, + @ActionInput(name = "power-level", label = "@text/actions.power-level.label", description = "@text/actions.power-level.description") @Nullable Integer powerLevel, + @ActionInput(name = "stop-soc", label = "@text/actions.stop-soc.label", description = "@text/actions.stop-soc.description") @Nullable Integer stopSOC, + @ActionInput(name = "enable-ac-charging", label = "@text/actions.enable-ac-charging.label", description = "@text/actions.enable-ac-charging.description") @Nullable Boolean enableAcCharging, + @ActionInput(name = "start-time", label = "@text/actions.start-time.label", description = "@text/actions.start-time.description") @Nullable String startTime, + @ActionInput(name = "stop-time", label = "@text/actions.stop-time.label", description = "@text/actions.stop-time.description") @Nullable String stopTime, + @ActionInput(name = "enable-program", label = "@text/actions.enable-program.label", description = "@text/actions.enable-program.description") @Nullable Boolean enableProgram) { + GrowattInverterHandler handler = this.handler; + if (handler != null) { + handler.setupBatteryProgram(programMode, powerLevel, stopSOC, enableAcCharging, startTime, stopTime, + enableProgram); + } else { + logger.warn("ThingHandler is null."); + } + } +} diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/cloud/GrowattApiException.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/cloud/GrowattApiException.java new file mode 100644 index 00000000000..3ab477cb1ad --- /dev/null +++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/cloud/GrowattApiException.java @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.growatt.internal.cloud; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link GrowattApiException} is thrown if a call to the Growatt cloud API server fails. + * + * @author Andrew Fiddian-Green - Initial contribution. + */ +@NonNullByDefault +public class GrowattApiException extends Exception { + + private static final long serialVersionUID = 218139823621683189L; + + public GrowattApiException(String message) { + super(message); + } + + public GrowattApiException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/cloud/GrowattCloud.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/cloud/GrowattCloud.java new file mode 100644 index 00000000000..4d3eeaa758d --- /dev/null +++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/cloud/GrowattCloud.java @@ -0,0 +1,904 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.growatt.internal.cloud; + +import java.lang.reflect.Type; +import java.net.HttpCookie; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.DateTimeException; +import java.time.Duration; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.api.ContentResponse; +import org.eclipse.jetty.client.api.Request; +import org.eclipse.jetty.client.util.FormContentProvider; +import org.eclipse.jetty.http.HttpMethod; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.util.Fields; +import org.openhab.binding.growatt.internal.GrowattBindingConstants; +import org.openhab.binding.growatt.internal.config.GrowattBridgeConfiguration; +import org.openhab.binding.growatt.internal.dto.GrowattDevice; +import org.openhab.binding.growatt.internal.dto.GrowattPlant; +import org.openhab.binding.growatt.internal.dto.GrowattPlantList; +import org.openhab.binding.growatt.internal.dto.GrowattUser; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSyntaxException; +import com.google.gson.reflect.TypeToken; + +/** + * The {@link GrowattCloud} class allows the binding to access the inverter state and settings via HTTP calls to the + * remote Growatt cloud API server (instead of receiving the data from the local Grott proxy server). + *

+ * This class is necessary since the Grott proxy server does not (yet) support easy access to some inverter register + * settings, such as the settings for the battery charging and discharging programs. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class GrowattCloud implements AutoCloseable { + + // JSON field names for the battery charging program + public static final String CHARGE_PROGRAM_POWER = "chargePowerCommand"; + public static final String CHARGE_PROGRAM_TARGET_SOC = "wchargeSOCLowLimit2"; + public static final String CHARGE_PROGRAM_ALLOW_AC_CHARGING = "acChargeEnable"; + public static final String CHARGE_PROGRAM_START_TIME = "forcedChargeTimeStart1"; + public static final String CHARGE_PROGRAM_STOP_TIME = "forcedChargeTimeStop1"; + public static final String CHARGE_PROGRAM_ENABLE = "forcedChargeStopSwitch1"; + + // JSON field names for the battery discharging program + public static final String DISCHARGE_PROGRAM_POWER = "disChargePowerCommand"; + public static final String DISCHARGE_PROGRAM_TARGET_SOC = "wdisChargeSOCLowLimit2"; + public static final String DISCHARGE_PROGRAM_START_TIME = "forcedDischargeTimeStart1"; + public static final String DISCHARGE_PROGRAM_STOP_TIME = "forcedDischargeTimeStop1"; + public static final String DISCHARGE_PROGRAM_ENABLE = "forcedDischargeStopSwitch1"; + + // API server URL + private static final String SERVER_URL = "https://server-api.growatt.com/"; + + // API end points + private static final String LOGIN_API_ENDPOINT = "newTwoLoginAPI.do"; + private static final String PLANT_LIST_API_ENDPOINT = "PlantListAPI.do"; + private static final String PLANT_INFO_API_ENDPOINT = "newTwoPlantAPI.do"; + private static final String NEW_TCP_SET_API_ENDPOINT = "newTcpsetAPI.do"; + + private static final String FMT_NEW_DEVICE_TYPE_API_DO = "new%sApi.do"; + + // command operations + private static final String OP_GET_ALL_DEVICE_LIST = "getAllDeviceList"; + + // enum of device types + private static enum DeviceType { + MIX, + MAX, + MIN, + SPA, + SPH, + TLX + } + + /* + * Map of device types vs. field parameters for GET requests to FMT_NEW_DEVICE_TYPE_API_DO end-points. + * Note: some values are guesses which have not yet been confirmed by users + */ + private static final Map SUPPORTED_TYPES_GET_PARAM = Map.of( + // @formatter:off + DeviceType.MIX, "getMixSetParams", + DeviceType.MAX, "getMaxSetData", + DeviceType.MIN, "getMinSetData", + DeviceType.SPA, "getSpaSetData", + DeviceType.SPH, "getSphSetData", + DeviceType.TLX, "getTlxSetData" + // @formatter:on + ); + + /* + * Map of device types vs. field parameters for POST commands to NEW_TCP_SET_API_ENDPOINT. + * Note: some values are guesses which have not yet been confirmed by users + */ + private static final Map SUPPORTED_TYPE_POST_PARAM = Map.of( + // @formatter:off + DeviceType.MIX, "mixSetApiNew", // was "mixSetApi" + DeviceType.MAX, "maxSetApi", + DeviceType.MIN, "minSetApi", + DeviceType.SPA, "spaSetApi", + DeviceType.SPH, "sphSet", + DeviceType.TLX, "tlxSet" + // @formatter:on + ); + + // enum to select charge resp. discharge program + private static enum ProgramType { + CHARGE, + DISCHARGE + } + + // enum of program modes + public static enum ProgramMode { + LOAD_FIRST, + BATTERY_FIRST, + GRID_FIRST + } + + // @formatter:off + private static final Type DEVICE_LIST_TYPE = new TypeToken>() {}.getType(); + // @formatter:on + + // HTTP headers (user agent is spoofed to mimic the Growatt Android Shine app) + private static final String USER_AGENT = "Dalvik/2.1.0 (Linux; U; Android 12; https://www.openhab.org)"; + private static final String FORM_CONTENT = "application/x-www-form-urlencoded"; + + private static final Duration HTTP_TIMEOUT = Duration.ofSeconds(10); + + private final Logger logger = LoggerFactory.getLogger(GrowattCloud.class); + private final HttpClient httpClient; + private final GrowattBridgeConfiguration configuration; + private final Gson gson = new Gson(); + private final List plantIds = new ArrayList<>(); + private final Map deviceIdTypeMap = new ConcurrentHashMap<>(); + + private String userId = ""; + + /** + * Constructor. + * + * @param configuration the bridge configuration parameters. + * @param httpClientFactory the OH core {@link HttpClientFactory} instance. + * @throws Exception if anything goes wrong. + */ + public GrowattCloud(GrowattBridgeConfiguration configuration, HttpClientFactory httpClientFactory) + throws Exception { + this.configuration = configuration; + this.httpClient = httpClientFactory.createHttpClient(GrowattBindingConstants.BINDING_ID); + this.httpClient.start(); + } + + @Override + public void close() throws Exception { + httpClient.stop(); + } + + /** + * Create a hash of the given password using normal MD5, except add 'c' if a byte of the digest is less than 10 + * + * @param password the plain text password + * @return the hash of the password + * @throws GrowattApiException if MD5 algorithm is not supported + */ + private static String createHash(String password) throws GrowattApiException { + MessageDigest md; + try { + md = MessageDigest.getInstance("MD5"); + } catch (NoSuchAlgorithmException e) { + throw new GrowattApiException("Hash algorithm error", e); + } + byte[] bytes = md.digest(password.getBytes()); + StringBuilder result = new StringBuilder(); + for (byte b : bytes) { + result.append(String.format("%02x", b)); + } + for (int i = 0; i < result.length(); i += 2) { + if (result.charAt(i) == '0') { + result.replace(i, i + 1, "c"); + } + } + return result.toString(); + } + + /** + * Refresh the login cookies. + * + * @throws GrowattApiException if any error occurs. + */ + private void refreshCookies() throws GrowattApiException { + List cookies = httpClient.getCookieStore().getCookies(); + if (cookies.isEmpty() || cookies.stream().anyMatch(HttpCookie::hasExpired)) { + postLoginCredentials(); + } + } + + /** + * Login to the server (if necessary) and then execute an HTTP request using the given HTTP method, to the given end + * point, and with the given request URL parameters and/or request form fields. If the cookies are not valid first + * login to the server before making the actual HTTP request. + * + * @param method the HTTP method to use. + * @param endPoint the API end point. + * @param params the request URL parameters (may be null). + * @param fields the request form fields (may be null). + * @return a Map of JSON elements containing the server response. + * @throws GrowattApiException if any error occurs. + */ + private Map doHttpRequest(HttpMethod method, String endPoint, + @Nullable Map params, @Nullable Fields fields) throws GrowattApiException { + refreshCookies(); + return doHttpRequestInner(method, endPoint, params, fields); + } + + /** + * Inner method to execute an HTTP request using the given HTTP method, to the given end point, and with the given + * request URL parameters and/or request form fields. + * + * @param method the HTTP method to use. + * @param endPoint the API end point. + * @param params the request URL parameters (may be null). + * @param fields the request form fields (may be null). + * @return a Map of JSON elements containing the server response. + * @throws GrowattApiException if any error occurs. + */ + private Map doHttpRequestInner(HttpMethod method, String endPoint, + @Nullable Map params, @Nullable Fields fields) throws GrowattApiException { + // + Request request = httpClient.newRequest(SERVER_URL + endPoint).method(method).agent(USER_AGENT) + .timeout(HTTP_TIMEOUT.getSeconds(), TimeUnit.SECONDS); + + if (params != null) { + params.entrySet().forEach(p -> request.param(p.getKey(), p.getValue())); + } + + if (fields != null) { + request.content(new FormContentProvider(fields), FORM_CONTENT); + } + + if (logger.isTraceEnabled()) { + logger.trace("{} {}{} {} {}", method, request.getPath(), params == null ? "" : "?" + request.getQuery(), + request.getVersion(), fields == null ? "" : "? " + FormContentProvider.convert(fields)); + } + + ContentResponse response; + try { + response = request.send(); + } catch (InterruptedException | TimeoutException | ExecutionException e) { + throw new GrowattApiException("HTTP I/O Exception", e); + } + + int status = response.getStatus(); + String content = response.getContentAsString(); + + logger.trace("HTTP {} {} {}", status, HttpStatus.getMessage(status), content); + + if (status != HttpStatus.OK_200) { + throw new GrowattApiException(String.format("HTTP %d %s", status, HttpStatus.getMessage(status))); + } + + if (content == null || content.isBlank()) { + throw new GrowattApiException("Response is " + (content == null ? "null" : "blank")); + } + + if (content.contains("")) { + logger.warn("HTTP {} {} {}", status, HttpStatus.getMessage(status), content); + throw new GrowattApiException("Response is HTML"); + } + + try { + JsonElement jsonObject = JsonParser.parseString(content).getAsJsonObject(); + if (jsonObject instanceof JsonObject jsonElement) { + return jsonElement.asMap(); + } + throw new GrowattApiException("Response JSON invalid"); + } catch (JsonSyntaxException | IllegalStateException e) { + throw new GrowattApiException("Response JSON syntax exception", e); + } + } + + /** + * Get the deviceType for the given deviceId. If the deviceIdTypeMap is empty then download it freshly. + * + * @param the deviceId to get. + * @return the deviceType. + * @throws GrowattApiException if any error occurs. + */ + private DeviceType getDeviceTypeChecked(String deviceId) throws GrowattApiException { + if (deviceIdTypeMap.isEmpty()) { + if (plantIds.isEmpty()) { + refreshCookies(); + } + for (String plantId : plantIds) { + for (GrowattDevice device : getPlantInfo(plantId)) { + try { + deviceIdTypeMap.put(device.getId(), DeviceType.valueOf(device.getType().toUpperCase())); + } catch (IllegalArgumentException e) { + // just ignore unsupported device types + } + } + } + logger.debug("Downloaded deviceTypes:{}", deviceIdTypeMap); + } + if (deviceId.isBlank()) { + throw new GrowattApiException("Device id is blank"); + } + DeviceType deviceType = deviceIdTypeMap.get(deviceId); + if (deviceType != null) { + return deviceType; + } + throw new GrowattApiException("Unsupported device:" + deviceId); + } + + /** + * Get the inverter device settings. + * + * @param the deviceId to get. + * @return a Map of JSON elements containing the server response. + * @throws GrowattApiException if any error occurs. + */ + public Map getDeviceSettings(String deviceId) throws GrowattApiException { + DeviceType deviceType = getDeviceTypeChecked(deviceId); + String dt = deviceType.name().toLowerCase(); + + String endPoint = String.format(FMT_NEW_DEVICE_TYPE_API_DO, dt.substring(0, 1).toUpperCase() + dt.substring(1)); + + Map params = new LinkedHashMap<>(); // keep params in order + params.put("op", Objects.requireNonNull(SUPPORTED_TYPES_GET_PARAM.get(deviceType))); + params.put("serialNum", deviceId); + params.put("kind", "0"); + + Map result = doHttpRequest(HttpMethod.GET, endPoint, params, null); + + JsonElement obj = result.get("obj"); + if (obj instanceof JsonObject object) { + Map map = object.asMap(); + Optional key = map.keySet().stream().filter(k -> k.toLowerCase().endsWith("bean")).findFirst(); + if (key.isPresent()) { + JsonElement beanJson = map.get(key.get()); + if (beanJson instanceof JsonObject bean) { + return bean.asMap(); + } + } + } + throw new GrowattApiException("Invalid JSON response"); + } + + /** + * Get the plant information. + * + * @param the plantId to get. + * @return a list of {@link GrowattDevice} containing the server response. + * @throws GrowattApiException if any error occurs. + */ + public List getPlantInfo(String plantId) throws GrowattApiException { + Map params = new LinkedHashMap<>(); // keep params in order + params.put("op", OP_GET_ALL_DEVICE_LIST); + params.put("plantId", plantId); + params.put("pageNum", "1"); + params.put("pageSize", "1"); + + Map result = doHttpRequest(HttpMethod.GET, PLANT_INFO_API_ENDPOINT, params, null); + + JsonElement deviceList = result.get("deviceList"); + if (deviceList instanceof JsonArray deviceArray) { + try { + List devices = gson.fromJson(deviceArray, DEVICE_LIST_TYPE); + if (devices != null) { + return devices; + } + } catch (JsonSyntaxException e) { + // fall through + } + } + throw new GrowattApiException("Invalid JSON response"); + } + + /** + * Get the plant list. + * + * @param the userId to get from. + * @return a {@link GrowattPlantList} containing the server response. + * @throws GrowattApiException if any error occurs. + */ + public GrowattPlantList getPlantList(String userId) throws GrowattApiException { + Map params = new LinkedHashMap<>(); // keep params in order + params.put("userId", userId); + + Map result = doHttpRequest(HttpMethod.GET, PLANT_LIST_API_ENDPOINT, params, null); + + JsonElement back = result.get("back"); + if (back instanceof JsonObject backObject) { + try { + GrowattPlantList plantList = gson.fromJson(backObject, GrowattPlantList.class); + if (plantList != null && plantList.getSuccess()) { + return plantList; + } + } catch (JsonSyntaxException e) { + // fall through + } + } + throw new GrowattApiException("Invalid JSON response"); + } + + /** + * Attempt to login to the remote server by posting the given user credentials. + * + * @throws GrowattApiException if any error occurs. + */ + private void postLoginCredentials() throws GrowattApiException { + String userName = configuration.userName; + if (userName == null || userName.isBlank()) { + throw new GrowattApiException("User name missing"); + } + String password = configuration.password; + if (password == null || password.isBlank()) { + throw new GrowattApiException("Password missing"); + } + + Fields fields = new Fields(); + fields.put("userName", userName); + fields.put("password", createHash(password)); + + Map result = doHttpRequestInner(HttpMethod.POST, LOGIN_API_ENDPOINT, null, fields); + + JsonElement back = result.get("back"); + if (back instanceof JsonObject backObject) { + try { + GrowattPlantList plantList = gson.fromJson(backObject, GrowattPlantList.class); + if (plantList != null && plantList.getSuccess()) { + GrowattUser user = plantList.getUserId(); + userId = user != null ? user.getId() : userId; + plantIds.clear(); + plantIds.addAll(plantList.getPlants().stream().map(GrowattPlant::getId).toList()); + logger.debug("Logged in userId:{}, plantIds:{}", userId, plantIds); + return; + } + } catch (JsonSyntaxException e) { + // fall through + } + } + throw new GrowattApiException("Login failed"); + } + + /** + * Post a command to setup the inverter battery charging program. + * + * @param the deviceId to set up + * @param programModeInt index of the type of program Load First (0) / Battery First (1) / Grid First (2) + * @param powerLevel the rate of charging / discharging + * @param stopSOC the SOC at which to stop charging / discharging + * @param enableAcCharging allow charging from AC power + * @param startTime the start time of the charging / discharging program + * @param stopTime the stop time of the charging / discharging program + * @param enableProgram charging / discharging program shall be enabled + * + * @throws GrowattApiException if any error occurs + */ + public void setupBatteryProgram(String deviceId, int programModeInt, @Nullable Integer powerLevel, + @Nullable Integer stopSOC, @Nullable Boolean enableAcCharging, @Nullable String startTime, + @Nullable String stopTime, @Nullable Boolean enableProgram) throws GrowattApiException { + // + if (deviceId.isBlank()) { + throw new GrowattApiException("Device id is blank"); + } + + ProgramMode programMode; + try { + programMode = ProgramMode.values()[programModeInt]; + } catch (IndexOutOfBoundsException e) { + throw new GrowattApiException("Program mode is out of range (0..2)"); + } + + DeviceType deviceType = getDeviceTypeChecked(deviceId); + switch (deviceType) { + + case MIX: + case SPA: + setTimeProgram(deviceId, deviceType, + programMode == ProgramMode.BATTERY_FIRST ? ProgramType.CHARGE : ProgramType.DISCHARGE, + powerLevel, stopSOC, enableAcCharging, startTime, stopTime, enableProgram); + return; + + case TLX: + if (enableAcCharging != null) { + setEnableAcCharging(deviceId, deviceType, enableAcCharging); + } + if (powerLevel != null) { + setPowerLevel(deviceId, deviceType, programMode, powerLevel); + } + if (stopSOC != null) { + setStopSOC(deviceId, deviceType, programMode, stopSOC); + } + if (startTime != null || stopTime != null || enableProgram != null) { + setTimeSegment(deviceId, deviceType, programMode, startTime, stopTime, enableProgram); + } + return; + + default: + } + throw new GrowattApiException("Unsupported device type:" + deviceType.name()); + } + + /** + * Look for an entry in the given Map, and return its value as a boolean. + * + * @param map the source map. + * @param key the key to search for in the map. + * @return the boolean value. + * @throws GrowattApiException if any error occurs. + */ + public static boolean mapGetBoolean(Map map, String key) throws GrowattApiException { + JsonElement element = map.get(key); + if (element instanceof JsonPrimitive primitive) { + if (primitive.isBoolean()) { + return primitive.getAsBoolean(); + } else if (primitive.isNumber() || primitive.isString()) { + try { + switch (primitive.getAsInt()) { + case 0: + return false; + case 1: + return true; + } + } catch (NumberFormatException e) { + throw new GrowattApiException("Boolean bad value", e); + } + } + } + throw new GrowattApiException("Boolean missing or bad value"); + } + + /** + * Look for an entry in the given Map, and return its value as an integer. + * + * @param map the source map. + * @param key the key to search for in the map. + * @return the integer value. + * @throws GrowattApiException if any error occurs. + */ + public static int mapGetInteger(Map map, String key) throws GrowattApiException { + JsonElement element = map.get(key); + if (element instanceof JsonPrimitive primitive) { + try { + return primitive.getAsInt(); + } catch (NumberFormatException e) { + throw new GrowattApiException("Integer bad value", e); + } + } + throw new GrowattApiException("Integer missing or bad value"); + } + + /** + * Look for an entry in the given Map, and return its value as a LocalTime. + * + * @param source the source map. + * @param key the key to search for in the map. + * @return the LocalTime. + * @throws GrowattApiException if any error occurs. + */ + public static LocalTime mapGetLocalTime(Map source, String key) throws GrowattApiException { + JsonElement element = source.get(key); + if ((element instanceof JsonPrimitive primitive) && primitive.isString()) { + try { + return localTimeOf(primitive.getAsString()); + } catch (DateTimeException e) { + throw new GrowattApiException("LocalTime bad value", e); + } + } + throw new GrowattApiException("LocalTime missing or bad value"); + } + + /** + * Parse a time formatted string into a LocalTime entity. + *

+ * Note: unlike the standard LocalTime.parse() method, this method accepts hour and minute fields from the Growatt + * server that are without leading zeros e.g. "1:1" and it accepts the conventional "01:01" format too. + * + * @param localTime a time formatted string e.g. "12:34" + * @return a corresponding LocalTime entity. + * @throws DateTimeException if any error occurs. + */ + public static LocalTime localTimeOf(String localTime) throws DateTimeException { + String splitParts[] = localTime.split(":"); + if (splitParts.length < 2) { + throw new DateTimeException("LocalTime bad value"); + } + try { + return LocalTime.of(Integer.valueOf(splitParts[0]), Integer.valueOf(splitParts[1])); + } catch (NumberFormatException | DateTimeException e) { + throw new DateTimeException("LocalTime bad value", e); + } + } + + /** + * Post a command to set up the inverter battery charging / discharging program. + * + * @param the deviceId to set up + * @param the deviceType to set up + * @param programType selects whether the program is for charge or discharge + * @param powerLevel the rate of charging / discharging 1%..100% + * @param stopSOC the SOC at which to stop the program 5%..100% + * @param enableAcCharging allow charging from AC power (only applies to hybrid/mix inverters) + * @param startTime the start time of the program + * @param stopTime the stop time of the program + * @param enableProgram the program shall be enabled + * + * @throws GrowattApiException if any error occurs + */ + private void setTimeProgram(String deviceId, DeviceType deviceType, ProgramType programType, + @Nullable Integer powerLevel, @Nullable Integer stopSOC, @Nullable Boolean enableAcCharging, + @Nullable String startTime, @Nullable String stopTime, @Nullable Boolean enableProgram) + throws GrowattApiException { + // + if (powerLevel == null || powerLevel < 1 || powerLevel > 100) { + throw new GrowattApiException("Power level parameter is null or out of range (1%..100%)"); + } + if (stopSOC == null || stopSOC < 5 || stopSOC > 100) { + throw new GrowattApiException("Target SOC parameter is null out of range (5%..100%)"); + } + if (startTime == null) { + throw new GrowattApiException("Start time parameter is null"); + } + if (stopTime == null) { + throw new GrowattApiException("Stop time parameter is null"); + } + if (enableProgram == null) { + throw new GrowattApiException("Program enable parameter is null"); + } + boolean isMixChargeCommand = deviceType == DeviceType.MIX && programType == ProgramType.CHARGE; + if (isMixChargeCommand && enableAcCharging == null) { + throw new GrowattApiException("Allow ac charging parameter is null"); + } + LocalTime localStartTime; + try { + localStartTime = GrowattCloud.localTimeOf(startTime); + } catch (DateTimeException e) { + throw new GrowattApiException("Start time is invalid"); + } + LocalTime localStopTime; + try { + localStopTime = GrowattCloud.localTimeOf(stopTime); + } catch (DateTimeException e) { + throw new GrowattApiException("Stop time is invalid"); + } + + Fields fields = new Fields(); + + fields.put("op", Objects.requireNonNull(SUPPORTED_TYPE_POST_PARAM.get(deviceType))); + fields.put("serialNum", deviceId); + fields.put("type", String.format("%s_ac_%s_time_period", deviceType.name().toLowerCase(), + programType.name().toLowerCase())); + + int paramId = 1; + + paramId = addParam(fields, paramId, String.format("%d", powerLevel)); + paramId = addParam(fields, paramId, String.format("%d", stopSOC)); + if (isMixChargeCommand) { + paramId = addParam(fields, paramId, enableAcCharging ? "1" : "0"); + } + paramId = addParam(fields, paramId, String.format("%02d", localStartTime.getHour())); + paramId = addParam(fields, paramId, String.format("%02d", localStartTime.getMinute())); + paramId = addParam(fields, paramId, String.format("%02d", localStopTime.getHour())); + paramId = addParam(fields, paramId, String.format("%02d", localStopTime.getMinute())); + paramId = addParam(fields, paramId, enableProgram ? "1" : "0"); + paramId = addParam(fields, paramId, "00"); + paramId = addParam(fields, paramId, "00"); + paramId = addParam(fields, paramId, "00"); + paramId = addParam(fields, paramId, "00"); + paramId = addParam(fields, paramId, "0"); + paramId = addParam(fields, paramId, "00"); + paramId = addParam(fields, paramId, "00"); + paramId = addParam(fields, paramId, "00"); + paramId = addParam(fields, paramId, "00"); + paramId = addParam(fields, paramId, "0"); + + postSetCommandForm(fields); + } + + /** + * Add a new entry in the given {@link Fields} map in the form "paramN" = paramValue where N is the parameter index. + * + * @param fields the map to be added to. + * @param parameterIndex the parameter index. + * @param parameterValue the parameter value. + * + * @return the next parameter index. + */ + private int addParam(Fields fields, int parameterIndex, String parameterValue) { + fields.put(String.format("param%d", parameterIndex), parameterValue); + return parameterIndex + 1; + } + + /** + * Inner method to execute a POST setup command using the given form fields. + * + * @param fields the form fields to be posted. + * + * @throws GrowattApiException if any error occurs + */ + private void postSetCommandForm(Fields fields) throws GrowattApiException { + Map result = doHttpRequest(HttpMethod.POST, NEW_TCP_SET_API_ENDPOINT, null, fields); + JsonElement success = result.get("success"); + if (success instanceof JsonPrimitive sucessPrimitive) { + if (sucessPrimitive.getAsBoolean()) { + return; + } + } + throw new GrowattApiException("Command failed"); + } + + /** + * Post a command to enable / disable ac charging. + * + * @param the deviceId to set up + * @param the deviceType to set up + * @param enableAcCharging enable or disable the function + * + * @throws GrowattApiException if any error occurs + */ + private void setEnableAcCharging(String deviceId, DeviceType deviceType, boolean enableAcCharging) + throws GrowattApiException { + // + Fields fields = new Fields(); + + fields.put("op", Objects.requireNonNull(SUPPORTED_TYPE_POST_PARAM.get(deviceType))); + fields.put("serialNum", deviceId); + fields.put("type", "ac_charge"); + fields.put("param1", enableAcCharging ? "1" : "0"); + + postSetCommandForm(fields); + } + + /** + * Post a command to set up a program charge / discharge power level. + * + * @param the deviceId to set up + * @param the deviceType to set up + * @param programMode the program mode that the setting shall apply to + * @param powerLevel the rate of charging / discharging 1%..100% + * + * @throws GrowattApiException if any error occurs + */ + private void setPowerLevel(String deviceId, DeviceType deviceType, ProgramMode programMode, int powerLevel) + throws GrowattApiException { + // + if (powerLevel < 1 || powerLevel > 100) { + throw new GrowattApiException("Power level out of range (1%..100%)"); + } + + String typeParam; + switch (programMode) { + case BATTERY_FIRST: + typeParam = "charge_power"; + break; + case GRID_FIRST: + case LOAD_FIRST: + typeParam = "discharge_power"; + break; + default: + throw new GrowattApiException("Unexpected exception"); + } + + Fields fields = new Fields(); + + fields.put("op", Objects.requireNonNull(SUPPORTED_TYPE_POST_PARAM.get(deviceType))); + fields.put("serialNum", deviceId); + fields.put("type", typeParam); + fields.put("param1", String.format("%d", powerLevel)); + + postSetCommandForm(fields); + } + + /** + * Post a command to set up a program target (stop) SOC level. + * + * @param the deviceId to set up + * @param the deviceType to set up + * @param programMode the program mode that the setting shall apply to + * @param stopSOC the SOC at which to stop the program 11%..100% + * + * @throws GrowattApiException if any error occurs + */ + private void setStopSOC(String deviceId, DeviceType deviceType, ProgramMode programMode, int stopSOC) + throws GrowattApiException { + // + if (stopSOC < 11 || stopSOC > 100) { + throw new GrowattApiException("Target SOC out of range (11%..100%)"); + } + + String typeParam; + switch (programMode) { + case BATTERY_FIRST: + typeParam = "charge_stop_soc"; + break; + case GRID_FIRST: + typeParam = "on_grid_discharge_stop_soc"; + break; + case LOAD_FIRST: + typeParam = "discharge_stop_soc"; + break; + default: + throw new GrowattApiException("Unexpected exception"); + } + + Fields fields = new Fields(); + + fields.put("op", Objects.requireNonNull(SUPPORTED_TYPE_POST_PARAM.get(deviceType))); + fields.put("serialNum", deviceId); + fields.put("type", typeParam); + fields.put("param1", String.format("%d", stopSOC)); + + postSetCommandForm(fields); + } + + /** + * Post a command to set up a time segment program. + * Note: uses separate dedicated time segments for Load First, Battery First, Grid First modes. + * + * @param the deviceId to set up + * @param the deviceType to set up + * @param programMode the program mode for the time segment + * @param startTime the start time of the program + * @param stopTime the stop time of the program + * @param enableProgram the program shall be enabled + * + * @throws GrowattApiException if any error occurs + */ + private void setTimeSegment(String deviceId, DeviceType deviceType, ProgramMode programMode, + @Nullable String startTime, @Nullable String stopTime, @Nullable Boolean enableProgram) + throws GrowattApiException { + // + if (startTime == null) { + throw new GrowattApiException("Start time parameter is null"); + } + if (stopTime == null) { + throw new GrowattApiException("Stop time parameter is null"); + } + if (enableProgram == null) { + throw new GrowattApiException("Program enable parameter is null"); + } + LocalTime localStartTime; + try { + localStartTime = GrowattCloud.localTimeOf(startTime); + } catch (DateTimeException e) { + throw new GrowattApiException("Start time is invalid"); + } + LocalTime localStopTime; + try { + localStopTime = GrowattCloud.localTimeOf(stopTime); + } catch (DateTimeException e) { + throw new GrowattApiException("Stop time is invalid"); + } + + Fields fields = new Fields(); + + fields.put("op", Objects.requireNonNull(SUPPORTED_TYPE_POST_PARAM.get(deviceType))); + fields.put("serialNum", deviceId); + fields.put("type", String.format("time_segment%d", programMode.ordinal() + 1)); + fields.put("param1", String.format("%d", programMode.ordinal())); + fields.put("param2", String.format("%02d", localStartTime.getHour())); + fields.put("param3", String.format("%02d", localStartTime.getMinute())); + fields.put("param4", String.format("%02d", localStopTime.getHour())); + fields.put("param5", String.format("%02d", localStopTime.getMinute())); + fields.put("param6", enableProgram ? "1" : "0"); + + postSetCommandForm(fields); + } +} diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/config/GrowattBridgeConfiguration.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/config/GrowattBridgeConfiguration.java new file mode 100644 index 00000000000..c47f7fb0d9b --- /dev/null +++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/config/GrowattBridgeConfiguration.java @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.growatt.internal.config; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * The {@link GrowattBridgeConfiguration} class contains fields mapping thing configuration parameters. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class GrowattBridgeConfiguration { + + public static final String USER_NAME = "userName"; + public static final String PASSWORD = "password"; + + public @Nullable String userName; + public @Nullable String password; +} diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/config/GrowattInverterConfiguration.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/config/GrowattInverterConfiguration.java new file mode 100644 index 00000000000..88e0b4aa2cb --- /dev/null +++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/config/GrowattInverterConfiguration.java @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.growatt.internal.config; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link GrowattInverterConfiguration} class contains fields mapping thing configuration parameters. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class GrowattInverterConfiguration { + + public static final String DEVICE_ID = "deviceId"; + + public String deviceId = ""; +} diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/discovery/GrowattDiscoveryService.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/discovery/GrowattDiscoveryService.java new file mode 100644 index 00000000000..cc39b3b7aa9 --- /dev/null +++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/discovery/GrowattDiscoveryService.java @@ -0,0 +1,68 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.growatt.internal.discovery; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.growatt.internal.GrowattBindingConstants; +import org.openhab.binding.growatt.internal.config.GrowattInverterConfiguration; +import org.openhab.core.config.discovery.AbstractDiscoveryService; +import org.openhab.core.config.discovery.DiscoveryResult; +import org.openhab.core.config.discovery.DiscoveryResultBuilder; +import org.openhab.core.i18n.LocaleProvider; +import org.openhab.core.i18n.TranslationProvider; +import org.openhab.core.thing.ThingUID; + +/** + * The {@link GrowattDiscoveryService} does discovery for Growatt inverters. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class GrowattDiscoveryService extends AbstractDiscoveryService { + + private final Map> bridgeInverterIds = new ConcurrentHashMap<>(); + + public GrowattDiscoveryService(TranslationProvider i18nProvider, LocaleProvider localeProvider) + throws IllegalArgumentException { + super(Set.of(GrowattBindingConstants.THING_TYPE_INVERTER), 5, false); + this.i18nProvider = i18nProvider; + this.localeProvider = localeProvider; + } + + public void putInverters(ThingUID bridgeUID, Set inverterIds) { + if (inverterIds.isEmpty()) { + bridgeInverterIds.remove(bridgeUID); + } else { + bridgeInverterIds.put(bridgeUID, inverterIds); + startScan(); + } + } + + @Override + protected void startScan() { + bridgeInverterIds.forEach((bridgeUID, inverterIds) -> { + inverterIds.forEach(inverterId -> { + DiscoveryResult inverter = DiscoveryResultBuilder + .create(new ThingUID(GrowattBindingConstants.THING_TYPE_INVERTER, bridgeUID, inverterId)) + .withBridge(bridgeUID).withLabel("@text/discovery.growatt-inverter") + .withProperty(GrowattInverterConfiguration.DEVICE_ID, inverterId) + .withRepresentationProperty(GrowattInverterConfiguration.DEVICE_ID).build(); + thingDiscovered(inverter); + }); + }); + } +} diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrottDevice.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrottDevice.java new file mode 100644 index 00000000000..140078d1b6a --- /dev/null +++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrottDevice.java @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.growatt.internal.dto; + +import java.lang.reflect.Type; +import java.util.ArrayList; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +import com.google.gson.annotations.SerializedName; +import com.google.gson.reflect.TypeToken; + +/** + * The {@link GrottDevice} is a DTO containing data fields received from the Grott application. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class GrottDevice { + + // @formatter:off + public static final Type GROTT_DEVICE_ARRAY = new TypeToken>() {}.getType(); + // @formatter:on + + private @Nullable @SerializedName("device") String deviceId; + private @Nullable GrottValues values; + + public String getDeviceId() { + String deviceId = this.deviceId; + return deviceId != null ? deviceId : "unknown"; + } + + public @Nullable GrottValues getValues() { + return values; + } +} diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrottValues.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrottValues.java new file mode 100644 index 00000000000..a8fc983b558 --- /dev/null +++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrottValues.java @@ -0,0 +1,188 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.growatt.internal.dto; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +import com.google.gson.annotations.SerializedName; + +/** + * The {@link GrottValues} is a DTO containing inverter value fields received from the Grott application. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class GrottValues { + + /** + * Convert Java field name to openHAB channel id + */ + public static String getChannelId(String fieldName) { + return fieldName.replace("_", "-"); + } + + /** + * Convert openHAB channel id to Java field name + */ + public static String getFieldName(String channelId) { + return channelId.replace("-", "_"); + } + + // @formatter:off + + // inverter state + public @Nullable @SerializedName(value = "pvstatus") Integer system_status; + + // solar AC and DC generation + public @Nullable @SerializedName(value = "pvpowerin") Integer pv_power; // from DC solar + public @Nullable @SerializedName(value = "pvpowerout") Integer inverter_power; // to AC mains + + // DC electric data for strings #1 and #2 + public @Nullable @SerializedName(value = "pv1voltage", alternate = { "vpv1" }) Integer pv1_voltage; + public @Nullable @SerializedName(value = "pv1current", alternate = { "buck1curr" }) Integer pv1_current; + public @Nullable @SerializedName(value = "pv1watt", alternate = { "ppv1" }) Integer pv1_power; + + public @Nullable @SerializedName(value = "pv2voltage", alternate = { "vpv2" }) Integer pv2_voltage; + public @Nullable @SerializedName(value = "pv2current", alternate = { "buck2curr" }) Integer pv2_current; + public @Nullable @SerializedName(value = "pv2watt", alternate = { "ppv2" }) Integer pv2_power; + + // AC mains electric data (1-phase resp. 3-phase) + public @Nullable @SerializedName(value = "pvfrequentie", alternate = { "line_freq", "outputfreq" }) Integer grid_frequency; + public @Nullable @SerializedName(value = "pvgridvoltage", alternate = { "grid_volt", "outputvolt", "voltage_l1" }) Integer grid_voltage_r; + public @Nullable @SerializedName(value = "pvgridvoltage2", alternate = { "voltage_l2" }) Integer grid_voltage_s; + public @Nullable @SerializedName(value = "pvgridvoltage3", alternate = { "voltage_l3" }) Integer grid_voltage_t; + public @Nullable @SerializedName(value = "Vac_RS", alternate = { "vacrs", "L1-2_voltage" }) Integer grid_voltage_rs; + public @Nullable @SerializedName(value = "Vac_ST", alternate = { "vacst", "L2-3_voltage" }) Integer grid_voltage_st; + public @Nullable @SerializedName(value = "Vac_TR", alternate = { "vactr", "L3-1_voltage" }) Integer grid_voltage_tr; + + // solar AC mains power + public @Nullable @SerializedName(value = "pvgridcurrent", alternate = { "OP_Curr", "Inv_Curr", "Current_l1" }) Integer inverter_current_r; + public @Nullable @SerializedName(value = "pvgridcurrent2", alternate = { "Current_l2" }) Integer inverter_current_s; + public @Nullable @SerializedName(value = "pvgridcurrent3", alternate = { "Current_l3" }) Integer inverter_current_t; + + public @Nullable @SerializedName(value = "pvgridpower", alternate = { "op_watt", "AC_InWatt" }) Integer inverter_power_r; + public @Nullable @SerializedName(value = "pvgridpower2") Integer inverter_power_s; + public @Nullable @SerializedName(value = "pvgridpower3") Integer inverter_power_t; + + // apparent power VA + public @Nullable @SerializedName(value = "op_va", alternate = { "AC_InVA" }) Integer inverter_va; + + // battery discharge / charge power + public @Nullable @SerializedName(value = "p1charge1", alternate = { "acchr_watt", "BatWatt", "bdc1_pchr" }) Integer charge_power; + public @Nullable @SerializedName(value = "pdischarge1", alternate = { "ACDischarWatt", "BatDischarWatt", "bdc1_pdischr" }) Integer discharge_power; + + // miscellaneous battery + public @Nullable @SerializedName(value = "ACCharCurr") Integer charge_current; + public @Nullable @SerializedName(value = "ACDischarVA", alternate = { "BatDischarVA", "acchar_VA" }) Integer discharge_va; + + // power exported to utility company + public @Nullable @SerializedName(value = "pactogridtot", alternate = { "ptogridtotal" }) Integer export_power; + public @Nullable @SerializedName(value = "pactogridr") Integer export_power_r; + public @Nullable @SerializedName(value = "pactogrids") Integer export_power_s; + public @Nullable @SerializedName(value = "pactogridt") Integer export_power_t; + + // power imported from utility company + public @Nullable @SerializedName(value = "pactousertot", alternate = { "ptousertotal", "pos_rev_act_power" }) Integer import_power; + public @Nullable @SerializedName(value = "pactouserr", alternate = { "act_power_l1" }) Integer import_power_r; + public @Nullable @SerializedName(value = "pactousers", alternate = { "act_power_l2" }) Integer import_power_s; + public @Nullable @SerializedName(value = "pactousert", alternate = { "act_power_l3" }) Integer import_power_t; + + // power delivered to internal load + public @Nullable @SerializedName(value = "plocaloadtot", alternate = { "ptoloadtotal" }) Integer load_power; + public @Nullable @SerializedName(value = "plocaloadr") Integer load_power_r; + public @Nullable @SerializedName(value = "plocaloads") Integer load_power_s; + public @Nullable @SerializedName(value = "plocaloadt") Integer load_power_t; + + // inverter AC energy + public @Nullable @SerializedName(value = "eactoday", alternate = { "pvenergytoday" }) Integer inverter_energy_today; + public @Nullable @SerializedName(value = "eactotal", alternate = { "pvenergytotal" }) Integer inverter_energy_total; + + // solar DC pv energy + public @Nullable @SerializedName(value = "epvtoday") Integer pv_energy_today; + public @Nullable @SerializedName(value = "epv1today", alternate = { "epv1tod" }) Integer pv1_energy_today; + public @Nullable @SerializedName(value = "epv2today", alternate = { "epv2tod" }) Integer pv2_energy_today; + + public @Nullable @SerializedName(value = "epvtotal") Integer pv_energy_total; + public @Nullable @SerializedName(value = "epv1total", alternate = { "epv1tot" }) Integer pv1_energy_total; + public @Nullable @SerializedName(value = "epv2total", alternate = { "epv2tot" }) Integer pv2_energy_total; + + // energy exported to utility company + public @Nullable @SerializedName(value = "etogrid_tod", alternate = { "etogridtoday" }) Integer export_energy_today; + public @Nullable @SerializedName(value = "etogrid_tot", alternate = { "etogridtotal", "rev_act_energy" }) Integer export_energy_total; + + // energy imported from utility company + public @Nullable @SerializedName(value = "etouser_tod", alternate = { "etousertoday" }) Integer import_energy_today; + public @Nullable @SerializedName(value = "etouser_tot", alternate = { "etousertotal", "pos_act_energy" }) Integer import_energy_total; + + // energy supplied to local load + public @Nullable @SerializedName(value = "elocalload_tod", alternate = { "eloadtoday" }) Integer load_energy_today; + public @Nullable @SerializedName(value = "elocalload_tot", alternate = { "eloadtotal" }) Integer load_energy_total; + + // charging energy from import + public @Nullable @SerializedName(value = "eacharge_today", alternate = { "eacCharToday", "eacchrtoday" }) Integer import_charge_energy_today; + public @Nullable @SerializedName(value = "eacharge_total", alternate = { "eacCharTotal", "eacchrtotal" }) Integer import_charge_energy_total; + + // charging energy from solar + public @Nullable @SerializedName(value = "eharge1_tod", alternate = { "echrtoday" }) Integer inverter_charge_energy_today; + public @Nullable @SerializedName(value = "eharge1_tot", alternate = { "echrtotal" }) Integer inverter_charge_energy_total; + + // discharging energy + public @Nullable @SerializedName(value = "edischarge1_tod", alternate = { "eacDischarToday", "ebatDischarToday", "edischrtoday" }) Integer discharge_energy_today; + public @Nullable @SerializedName(value = "edischarge1_tot", alternate = { "eacDischarTotal", "ebatDischarTotal", "edischrtotal" }) Integer discharge_energy_total; + + // inverter up time + public @Nullable @SerializedName(value = "totworktime") Integer total_work_time; + + // bus voltages + public @Nullable @SerializedName(value = "pbusvolt", alternate = { "bus_volt", "pbusvoltage" }) Integer p_bus_voltage; + public @Nullable @SerializedName(value = "nbusvolt", alternate = { "nbusvoltage" }) Integer n_bus_voltage; + public @Nullable @SerializedName(value = "spbusvolt") Integer sp_bus_voltage; + + // temperatures + public @Nullable @SerializedName(value = "pvtemperature", alternate = { "dcdctemp", "buck1_ntc" }) Integer pv_temperature; + public @Nullable @SerializedName(value = "pvipmtemperature", alternate = { "invtemp" }) Integer pv_ipm_temperature; + public @Nullable @SerializedName(value = "pvboosttemp", alternate = { "pvboottemperature", "temp3" }) Integer pv_boost_temperature; + public @Nullable @SerializedName(value = "temp4") Integer temperature_4; + public @Nullable @SerializedName(value = "buck2_ntc", alternate = { "temp5" }) Integer pv2_temperature; + + // battery data + public @Nullable @SerializedName(value = "batterytype") Integer battery_type; + public @Nullable @SerializedName(value = "batttemp", alternate = { "bdc1_tempa" }) Integer battery_temperature; + public @Nullable @SerializedName(value = "vbat", alternate = { "uwBatVolt_DSP", "bat_Volt", "bms_batteryvolt" }) Integer battery_voltage; + public @Nullable @SerializedName(value = "bat_dsp") Integer battery_display; + public @Nullable @SerializedName(value = "SOC", alternate = { "batterySOC", "bms_soc" }) Integer battery_soc; + + // fault codes + public @Nullable @SerializedName(value = "systemfaultword0", alternate = { "isof", "faultBit" }) Integer system_fault_0; + public @Nullable @SerializedName(value = "systemfaultword1", alternate = { "gfcif", "faultValue" }) Integer system_fault_1; + public @Nullable @SerializedName(value = "systemfaultword2", alternate = { "dcif", "warningBit" }) Integer system_fault_2; + public @Nullable @SerializedName(value = "systemfaultword3", alternate = { "vpvfault", "warningValue" }) Integer system_fault_3; + public @Nullable @SerializedName(value = "systemfaultword4", alternate = { "vacfault" }) Integer system_fault_4; + public @Nullable @SerializedName(value = "systemfaultword5", alternate = { "facfault" }) Integer system_fault_5; + public @Nullable @SerializedName(value = "systemfaultword6", alternate = { "tempfault" }) Integer system_fault_6; + public @Nullable @SerializedName(value = "systemfaultword7", alternate = { "faultcode" }) Integer system_fault_7; + + // miscellaneous + public @Nullable @SerializedName(value = "uwsysworkmode") Integer system_work_mode; + public @Nullable @SerializedName(value = "spdspstatus") Integer sp_display_status; + public @Nullable @SerializedName(value = "constantPowerOK") Integer constant_power_ok; + public @Nullable @SerializedName(value = "loadpercent") Integer load_percent; + + // reactive 'power' resp. 'energy' + public @Nullable @SerializedName(value = "rac", alternate = { "react_power" }) Integer rac; + public @Nullable @SerializedName(value = "eractoday", alternate = { "react_energy_kvar" }) Integer erac_today; + public @Nullable @SerializedName(value = "eractotal") Integer erac_total; + + // @formatter:on +} diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrowattDevice.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrowattDevice.java new file mode 100644 index 00000000000..bfabe18412f --- /dev/null +++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrowattDevice.java @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.growatt.internal.dto; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * The {@link GrowattDevice} is a DTO containing device data fields received from the Growatt cloud server. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class GrowattDevice { + + private @Nullable String deviceType; + private @Nullable String deviceSn; + + public String getId() { + String deviceSn = this.deviceSn; + return deviceSn != null ? deviceSn : ""; + } + + public String getType() { + String deviceType = this.deviceType; + return deviceType != null ? deviceType : ""; + } +} diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrowattPlant.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrowattPlant.java new file mode 100644 index 00000000000..d66cb696580 --- /dev/null +++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrowattPlant.java @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.growatt.internal.dto; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * The {@link GrowattPlant} is a DTO containing plant data fields received from the Growatt cloud server. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class GrowattPlant { + + private @Nullable String plantId; + private @Nullable String plantName; + + public String getId() { + String plantId = this.plantId; + return plantId != null ? plantId : ""; + } + + public String getName() { + String plantName = this.plantName; + return plantName != null ? plantName : ""; + } +} diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrowattPlantList.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrowattPlantList.java new file mode 100644 index 00000000000..ac6f61cea6b --- /dev/null +++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrowattPlantList.java @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.growatt.internal.dto; + +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * The {@link GrowattPlantList} is a DTO containing plant list and user data fields received from the Growatt cloud + * server. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class GrowattPlantList { + + private @Nullable List data; + private @Nullable GrowattUser user; + private @Nullable Boolean success; + + public List getPlants() { + List data = this.data; + return data != null ? data : List.of(); + } + + public Boolean getSuccess() { + Boolean success = this.success; + return success != null ? success : false; + } + + public @Nullable GrowattUser getUserId() { + return user; + } +} diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrowattUser.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrowattUser.java new file mode 100644 index 00000000000..e3a9dbacc52 --- /dev/null +++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/GrowattUser.java @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.growatt.internal.dto; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * The {@link GrowattUser} is a DTO containing user data fields received from the Growatt cloud server. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class GrowattUser { + + private @Nullable String id; + + public String getId() { + String id = this.id; + return id != null ? id : ""; + } +} diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/helper/GrottIntegerDeserializer.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/helper/GrottIntegerDeserializer.java new file mode 100644 index 00000000000..a105c515ac5 --- /dev/null +++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/helper/GrottIntegerDeserializer.java @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.growatt.internal.dto.helper; + +import java.lang.reflect.Type; +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; + +/** + * Special deserializer for integer values. It processes inputs which overflow the Integer.MAX_VALUE limit by + * transposing them to negative numbers by means of the 2's complement process. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class GrottIntegerDeserializer implements JsonDeserializer { + + private static final long INT_BIT_MASK = 0xffffffff; + + @Override + public @NonNull Integer deserialize(@Nullable JsonElement json, @Nullable Type typeOfT, + @Nullable JsonDeserializationContext context) throws JsonParseException { + long value = Long.parseLong(Objects.requireNonNull(json).getAsString()); + if (value > Integer.MAX_VALUE) { + // transpose values above Integer.MAX_VALUE to a negative int by 2's complement + return Integer.valueOf(1 - (int) (value ^ INT_BIT_MASK)); + } + return Long.valueOf(value).intValue(); + } +} diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/helper/GrottValuesHelper.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/helper/GrottValuesHelper.java new file mode 100644 index 00000000000..4b33af11671 --- /dev/null +++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/dto/helper/GrottValuesHelper.java @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.growatt.internal.dto.helper; + +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.growatt.internal.GrowattChannels; +import org.openhab.binding.growatt.internal.GrowattChannels.UoM; +import org.openhab.binding.growatt.internal.dto.GrottValues; +import org.openhab.core.library.types.QuantityType; + +/** + * Helper routines for the {@link GrottValues} DTO class. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class GrottValuesHelper { + + /** + * Return the valid values from the given target DTO in a map between channel id and respective QuantityType states. + * + * @return a map of channel ids and respective QuantityType state values. + */ + public static Map> getChannelStates(GrottValues target) + throws NoSuchFieldException, SecurityException, IllegalAccessException, IllegalArgumentException { + Map> map = new HashMap<>(); + GrowattChannels.getMap().entrySet().forEach(entry -> { + String channelId = entry.getKey(); + try { + Object field = target.getClass().getField(GrottValues.getFieldName(channelId)).get(target); + if (field instanceof Integer) { + UoM uom = entry.getValue(); + map.put(channelId, QuantityType.valueOf(((Integer) field).doubleValue() / uom.divisor, uom.units)); + } + } catch (NoSuchFieldException | SecurityException | IllegalAccessException | IllegalArgumentException e) { + // Exceptions should never actually occur at run time; nevertheless the caller logs if one would occur.. + // - NoSuchFieldException never occurs since we have explicitly tested this in the JUnit tests. + // - SecurityException, IllegalAccessException never occur since all fields are public. + // - IllegalArgumentException never occurs since we are explicitly working within this same class. + } + }); + return map; + } +} diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/factory/GrowattHandlerFactory.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/factory/GrowattHandlerFactory.java new file mode 100644 index 00000000000..402cb9c86c3 --- /dev/null +++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/factory/GrowattHandlerFactory.java @@ -0,0 +1,153 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.growatt.internal.factory; + +import static org.openhab.binding.growatt.internal.GrowattBindingConstants.*; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Hashtable; +import java.util.Objects; +import java.util.Set; + +import javax.servlet.ServletException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.growatt.internal.discovery.GrowattDiscoveryService; +import org.openhab.binding.growatt.internal.handler.GrowattBridgeHandler; +import org.openhab.binding.growatt.internal.handler.GrowattInverterHandler; +import org.openhab.binding.growatt.internal.servlet.GrowattHttpServlet; +import org.openhab.core.config.discovery.DiscoveryService; +import org.openhab.core.i18n.LocaleProvider; +import org.openhab.core.i18n.TranslationProvider; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.ThingUID; +import org.openhab.core.thing.binding.BaseThingHandlerFactory; +import org.openhab.core.thing.binding.ThingHandler; +import org.openhab.core.thing.binding.ThingHandlerFactory; +import org.osgi.framework.ServiceRegistration; +import org.osgi.service.component.ComponentContext; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.http.HttpService; +import org.osgi.service.http.NamespaceException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link GrowattHandlerFactory} is responsible for creating things and thing + * handlers. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +@Component(configurationPid = "binding.growatt", service = ThingHandlerFactory.class) +public class GrowattHandlerFactory extends BaseThingHandlerFactory { + + private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_BRIDGE, THING_TYPE_INVERTER); + + private final Logger logger = LoggerFactory.getLogger(GrowattHandlerFactory.class); + + private final HttpService httpService; + private final HttpClientFactory httpClientFactory; + private final TranslationProvider i18nProvider; + private final LocaleProvider localeProvider; + private final Set bridges = Collections.synchronizedSet(new HashSet<>()); + private final GrowattHttpServlet httpServlet = new GrowattHttpServlet(); + + private @Nullable GrowattDiscoveryService discoveryService; + private @Nullable ServiceRegistration discoveryServiceRegistration; + + @Activate + public GrowattHandlerFactory(@Reference HttpService httpService, @Reference HttpClientFactory httpClientFactory, + @Reference TranslationProvider i18nProvider, @Reference LocaleProvider localeProvider) { + this.httpService = httpService; + this.httpClientFactory = httpClientFactory; + this.i18nProvider = i18nProvider; + this.localeProvider = localeProvider; + try { + httpService.registerServlet(GrowattHttpServlet.PATH, httpServlet, null, null); + } catch (ServletException | NamespaceException e) { + logger.warn("GrowattHandlerFactory() failed to register servlet", e); + } + } + + @Override + protected @Nullable ThingHandler createHandler(Thing thing) { + ThingTypeUID thingTypeUID = thing.getThingTypeUID(); + + if (THING_TYPE_BRIDGE.equals(thingTypeUID)) { + discoveryRegister(); + bridges.add(thing.getUID()); + return new GrowattBridgeHandler((Bridge) thing, Objects.requireNonNull(httpServlet), + Objects.requireNonNull(discoveryService), httpClientFactory); + } + + if (THING_TYPE_INVERTER.equals(thingTypeUID)) { + return new GrowattInverterHandler(thing); + } + + return null; + } + + @Override + protected void deactivate(ComponentContext componentContext) { + bridges.clear(); + discoveryUnregister(); + httpService.unregister(GrowattHttpServlet.PATH); + super.deactivate(componentContext); + } + + private void discoveryRegister() { + GrowattDiscoveryService discoveryService = this.discoveryService; + if (discoveryService == null) { + discoveryService = new GrowattDiscoveryService(i18nProvider, localeProvider); + this.discoveryService = discoveryService; + } + ServiceRegistration temp = this.discoveryServiceRegistration; + if (temp == null) { + temp = bundleContext.registerService(DiscoveryService.class.getName(), discoveryService, new Hashtable<>()); + this.discoveryServiceRegistration = temp; + } + } + + private void discoveryUnregister() { + ServiceRegistration discoveryServiceRegistration = this.discoveryServiceRegistration; + if (discoveryServiceRegistration != null) { + discoveryServiceRegistration.unregister(); + } + this.discoveryService = null; + this.discoveryServiceRegistration = null; + } + + @Override + protected void removeHandler(ThingHandler thingHandler) { + if (thingHandler instanceof GrowattBridgeHandler) { + bridges.remove(thingHandler.getThing().getUID()); + if (bridges.isEmpty()) { + discoveryUnregister(); + } + } + super.removeHandler(thingHandler); + } + + @Override + public boolean supportsThingType(ThingTypeUID thingTypeUID) { + return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID); + } +} diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/handler/GrowattBridgeHandler.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/handler/GrowattBridgeHandler.java new file mode 100644 index 00000000000..e10dc5e006c --- /dev/null +++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/handler/GrowattBridgeHandler.java @@ -0,0 +1,143 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.growatt.internal.handler; + +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.growatt.internal.cloud.GrowattCloud; +import org.openhab.binding.growatt.internal.config.GrowattBridgeConfiguration; +import org.openhab.binding.growatt.internal.discovery.GrowattDiscoveryService; +import org.openhab.binding.growatt.internal.dto.GrottDevice; +import org.openhab.binding.growatt.internal.dto.helper.GrottIntegerDeserializer; +import org.openhab.binding.growatt.internal.servlet.GrowattHttpServlet; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.binding.BaseBridgeHandler; +import org.openhab.core.types.Command; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import com.google.gson.JsonSyntaxException; + +/** + * The {@link GrowattBridgeHandler} is a bridge handler for accessing Growatt inverters via the Grott application. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class GrowattBridgeHandler extends BaseBridgeHandler { + + private final Logger logger = LoggerFactory.getLogger(GrowattBridgeHandler.class); + private final Gson gson = new GsonBuilder().registerTypeAdapter(Integer.class, new GrottIntegerDeserializer()) + .create(); + private final GrowattDiscoveryService discoveryService; + private final Map inverters = new HashMap<>(); + private final GrowattHttpServlet httpServlet; + private final HttpClientFactory httpClientFactory; + + private @Nullable GrowattCloud growattCloud; + + public GrowattBridgeHandler(Bridge bridge, GrowattHttpServlet httpServlet, GrowattDiscoveryService discoveryService, + HttpClientFactory httpClientFactory) { + super(bridge); + this.httpServlet = httpServlet; + this.discoveryService = discoveryService; + this.httpClientFactory = httpClientFactory; + } + + @Override + public void dispose() { + inverters.clear(); + httpServlet.handlerRemove(this); + discoveryService.putInverters(thing.getUID(), inverters.keySet()); + } + + public GrowattCloud getGrowattCloud() throws IllegalStateException { + GrowattCloud growattCloud = this.growattCloud; + if (growattCloud == null) { + try { + growattCloud = new GrowattCloud(getConfigAs(GrowattBridgeConfiguration.class), httpClientFactory); + } catch (Exception e) { + throw new IllegalStateException("GrowattCloud not created", e); + } + this.growattCloud = growattCloud; + } + return growattCloud; + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + // everything is read only so do nothing + } + + /** + * Process JSON content posted to the Grott application servlet. + */ + @SuppressWarnings("null") + public void handleGrottContent(String json) { + logger.trace("handleGrottContent() json:{}", json); + JsonElement jsonElement; + try { + jsonElement = JsonParser.parseString(json); + if (jsonElement.isJsonPrimitive()) { + // strip double escaping from Grott JSON + jsonElement = JsonParser.parseString(jsonElement.getAsString()); + } + if (!jsonElement.isJsonObject()) { + throw new JsonSyntaxException("Unsupported JSON element type"); + } + } catch (JsonSyntaxException e) { + logger.debug("handleGrottContent() invalid JSON '{}'", json, e); + return; + } + try { + GrottDevice inverter = gson.fromJson(jsonElement, GrottDevice.class); + if (inverter == null) { + throw new JsonSyntaxException("Inverter object is null"); + } + putInverter(inverter); + } catch (JsonSyntaxException e) { + logger.debug("handleGrottContent() error parsing JSON '{}'", json, e); + return; + } + getThing().getThings().stream().map(thing -> thing.getHandler()) + .filter(handler -> (handler instanceof GrowattInverterHandler)) + .forEach(handler -> ((GrowattInverterHandler) handler).updateInverters(inverters.values())); + } + + @Override + public void initialize() { + httpServlet.handlerAdd(this); + updateStatus(ThingStatus.ONLINE); + } + + /** + * Put the given GrottDevice in our inverters map, and notify the discovery service if it was not already there. + * + * @param inverter a GrottDevice inverter object. + */ + private void putInverter(GrottDevice inverter) { + if (inverters.put(inverter.getDeviceId(), inverter) == null) { + discoveryService.putInverters(thing.getUID(), inverters.keySet()); + } + } +} diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/handler/GrowattInverterHandler.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/handler/GrowattInverterHandler.java new file mode 100644 index 00000000000..eeb960461f5 --- /dev/null +++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/handler/GrowattInverterHandler.java @@ -0,0 +1,194 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.growatt.internal.handler; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.growatt.internal.action.GrowattActions; +import org.openhab.binding.growatt.internal.cloud.GrowattApiException; +import org.openhab.binding.growatt.internal.cloud.GrowattCloud; +import org.openhab.binding.growatt.internal.config.GrowattInverterConfiguration; +import org.openhab.binding.growatt.internal.dto.GrottDevice; +import org.openhab.binding.growatt.internal.dto.GrottValues; +import org.openhab.binding.growatt.internal.dto.helper.GrottValuesHelper; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.Channel; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.binding.BaseThingHandler; +import org.openhab.core.thing.binding.ThingHandlerService; +import org.openhab.core.types.Command; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link GrowattInverterHandler} is a thing handler for Growatt inverters. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class GrowattInverterHandler extends BaseThingHandler { + + // data-logger sends packets each 5 minutes; timeout means 2 packets missed + private static final int AWAITING_DATA_TIMEOUT_MINUTES = 11; + + private final Logger logger = LoggerFactory.getLogger(GrowattInverterHandler.class); + + private String deviceId = "unknown"; + + private @Nullable ScheduledFuture awaitingDataTimeoutTask; + + public GrowattInverterHandler(Thing thing) { + super(thing); + } + + @Override + public void dispose() { + ScheduledFuture task = awaitingDataTimeoutTask; + if (task != null) { + task.cancel(true); + } + } + + @Override + public Collection> getServices() { + return List.of(GrowattActions.class); + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + // everything is read only so do nothing + } + + @Override + public void initialize() { + GrowattInverterConfiguration config = getConfigAs(GrowattInverterConfiguration.class); + deviceId = config.deviceId; + thing.setProperty(GrowattInverterConfiguration.DEVICE_ID, deviceId); + updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.NONE, "@text/status.awaiting-data"); + scheduleAwaitingDataTimeoutTask(); + logger.debug("initialize() thing has {} channels", thing.getChannels().size()); + } + + private void scheduleAwaitingDataTimeoutTask() { + ScheduledFuture task = awaitingDataTimeoutTask; + if (task != null) { + task.cancel(true); + } + awaitingDataTimeoutTask = scheduler.schedule(() -> { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + "@text/status.awaiting-data-timeout"); + }, AWAITING_DATA_TIMEOUT_MINUTES, TimeUnit.MINUTES); + } + + /** + * Receives a collection of GrottDevice inverter objects containing potential data for this thing. If the collection + * contains an entry matching the things's deviceId, and it contains GrottValues, then process it further. Otherwise + * go offline with a configuration error. + * + * @param inverters collection of GrottDevice objects. + */ + public void updateInverters(Collection inverters) { + inverters.stream().filter(inverter -> deviceId.equals(inverter.getDeviceId())) + .map(inverter -> inverter.getValues()).filter(values -> values != null).findAny() + .ifPresentOrElse(values -> { + updateStatus(ThingStatus.ONLINE); + scheduleAwaitingDataTimeoutTask(); + updateInverterValues(values); + }, () -> { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR); + }); + } + + /** + * Receives a GrottValues object containing state values for this thing. Process the respective values and update + * the channels accordingly. + * + * @param inverter a GrottDevice object containing the new status values. + */ + public void updateInverterValues(GrottValues inverterValues) { + // get channel states + Map> channelStates; + try { + channelStates = GrottValuesHelper.getChannelStates(inverterValues); + } catch (NoSuchFieldException | SecurityException | IllegalAccessException | IllegalArgumentException e) { + logger.warn("updateInverterValues() unexpected exception:{}, message:{}", e.getClass().getName(), + e.getMessage(), e); + return; + } + + // find unused channels + List actualChannels = thing.getChannels(); + List unusedChannels = actualChannels.stream() + .filter(channel -> !channelStates.containsKey(channel.getUID().getId())).collect(Collectors.toList()); + + // remove unused channels + if (!unusedChannels.isEmpty()) { + updateThing(editThing().withoutChannels(unusedChannels).build()); + logger.debug("updateInverterValues() channel count {} reduced by {} to {}", actualChannels.size(), + unusedChannels.size(), thing.getChannels().size()); + } + + List thingChannelIds = thing.getChannels().stream().map(channel -> channel.getUID().getId()) + .collect(Collectors.toList()); + + // update channel states + channelStates.forEach((channelId, state) -> { + if (thingChannelIds.contains(channelId)) { + updateState(channelId, state); + } else { + logger.warn("updateInverterValues() channel '{}' not found; try re-creating the thing", channelId); + } + }); + } + + private GrowattCloud getGrowattCloud() throws IllegalStateException { + Bridge bridge = getBridge(); + if (bridge != null && (bridge.getHandler() instanceof GrowattBridgeHandler bridgeHandler)) { + return bridgeHandler.getGrowattCloud(); + } + throw new IllegalStateException("Unable to get GrowattCloud from bridge handler"); + } + + /** + * This method is called from a Rule Action to setup the battery charging program. + * + * @param programMode indicates if the program is Load first (0), Battery first (1), Grid first (2) + * @param powerLevel the rate of charging / discharging 0%..100% + * @param stopSOC the SOC at which to stop charging / discharging 0%..100% + * @param enableAcCharging allow the battery to be charged from AC power + * @param startTime the start time of the charging program; a time formatted string e.g. "12:34" + * @param stopTime the stop time of the charging program; a time formatted string e.g. "12:34" + * @param enableProgram charge / discharge program shall be enabled + */ + public void setupBatteryProgram(Integer programMode, @Nullable Integer powerLevel, @Nullable Integer stopSOC, + @Nullable Boolean enableAcCharging, @Nullable String startTime, @Nullable String stopTime, + @Nullable Boolean enableProgram) { + try { + getGrowattCloud().setupBatteryProgram(deviceId, programMode, powerLevel, stopSOC, enableAcCharging, + startTime, stopTime, enableProgram); + } catch (GrowattApiException e) { + logger.warn("setupBatteryProgram() error", e); + } + } +} diff --git a/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/servlet/GrowattHttpServlet.java b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/servlet/GrowattHttpServlet.java new file mode 100644 index 00000000000..831fc3bb537 --- /dev/null +++ b/bundles/org.openhab.binding.growatt/src/main/java/org/openhab/binding/growatt/internal/servlet/GrowattHttpServlet.java @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.growatt.internal.servlet; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.core.MediaType; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.growatt.internal.handler.GrowattBridgeHandler; + +/** + * The {@link GrowattHttpServlet} is an HttpServlet to handle data posted by the Grott application. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class GrowattHttpServlet extends HttpServlet { + + public static final String PATH = "/growatt"; + + private static final String HTML = "" + // @formatter:off + + "" + + "" + + "

Growatt Binding Servlet

" + + "

 

" + + "

Status: %s

" + + "" + + ""; + // @formatter:on + + private static final String COLOR_READY = "ff6600"; + private static final String COLOR_ONLINE = "339966"; + private static final String MESSAGE_READY = "Ready"; + private static final String MESSAGE_ONLINE = "Bridge Online"; + + private static final long serialVersionUID = 36178542423191036L; + + private final Set handlers = Collections.synchronizedSet(new HashSet<>()); + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse response) throws ServletException, IOException { + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType(MediaType.TEXT_HTML); + response.getWriter().write(String.format(HTML, handlers.isEmpty() ? COLOR_READY : COLOR_ONLINE, + handlers.isEmpty() ? COLOR_READY : MESSAGE_ONLINE)); + } + + @Override + protected void doPost(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + response.setStatus(HttpServletResponse.SC_OK); + response.getWriter().write(handlers.isEmpty() ? MESSAGE_READY : MESSAGE_ONLINE); + if (request.getContentLength() > 0) { + String content = new String(request.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + handlers.forEach(handler -> handler.handleGrottContent(content)); + } + } + + public void handlerAdd(GrowattBridgeHandler handler) { + handlers.add(handler); + } + + public void handlerRemove(GrowattBridgeHandler handler) { + handlers.remove(handler); + } +} diff --git a/bundles/org.openhab.binding.growatt/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.binding.growatt/src/main/resources/OH-INF/addon/addon.xml new file mode 100644 index 00000000000..2c52b5e92ac --- /dev/null +++ b/bundles/org.openhab.binding.growatt/src/main/resources/OH-INF/addon/addon.xml @@ -0,0 +1,11 @@ + + + + binding + Growatt Binding + This is the binding for Growatt solar inverters. + hybrid + + diff --git a/bundles/org.openhab.binding.growatt/src/main/resources/OH-INF/i18n/growatt.properties b/bundles/org.openhab.binding.growatt/src/main/resources/OH-INF/i18n/growatt.properties new file mode 100644 index 00000000000..6c11e744716 --- /dev/null +++ b/bundles/org.openhab.binding.growatt/src/main/resources/OH-INF/i18n/growatt.properties @@ -0,0 +1,241 @@ +# add-on + +addon.growatt.name = Growatt Binding +addon.growatt.description = This is the binding for Growatt solar inverters. + +# thing types + +thing-type.growatt.bridge.label = Growatt Bridge +thing-type.growatt.bridge.description = Bridge Thing for Growatt Binding +thing-type.growatt.inverter.label = Growatt Inverter +thing-type.growatt.inverter.description = Inverter Thing for Growatt Binding +thing-type.growatt.inverter.channel.battery-display.label = Battery Display +thing-type.growatt.inverter.channel.battery-display.description = Battery display code. +thing-type.growatt.inverter.channel.battery-soc.label = Battery Charge +thing-type.growatt.inverter.channel.battery-soc.description = Battery state of charge. +thing-type.growatt.inverter.channel.battery-temperature.label = Battery Temperature +thing-type.growatt.inverter.channel.battery-temperature.description = Battery temperature. +thing-type.growatt.inverter.channel.battery-type.label = Battery Type +thing-type.growatt.inverter.channel.battery-type.description = Type code of the battery. +thing-type.growatt.inverter.channel.battery-voltage.label = Battery Voltage +thing-type.growatt.inverter.channel.battery-voltage.description = Battery voltage. +thing-type.growatt.inverter.channel.charge-current.label = Charge Current +thing-type.growatt.inverter.channel.charge-current.description = Charge current to battery. +thing-type.growatt.inverter.channel.charge-power.label = Charge Power +thing-type.growatt.inverter.channel.charge-power.description = Charge power to battery. +thing-type.growatt.inverter.channel.constant-power-ok.label = Constant Power OK +thing-type.growatt.inverter.channel.constant-power-ok.description = Constant power OK code. +thing-type.growatt.inverter.channel.discharge-energy-today.label = Battery Energy Today +thing-type.growatt.inverter.channel.discharge-energy-today.description = Energy consumed from battery today. +thing-type.growatt.inverter.channel.discharge-energy-total.label = Battery Energy Total +thing-type.growatt.inverter.channel.discharge-energy-total.description = Total energy consumed from battery. +thing-type.growatt.inverter.channel.discharge-power.label = Discharge Power +thing-type.growatt.inverter.channel.discharge-power.description = Discharge power from battery. +thing-type.growatt.inverter.channel.discharge-va.label = Discharge VA +thing-type.growatt.inverter.channel.discharge-va.description = Discharge VA from battery. +thing-type.growatt.inverter.channel.erac-today.label = Reactive Energy Today +thing-type.growatt.inverter.channel.erac-today.description = Reactive energy supplied today. +thing-type.growatt.inverter.channel.erac-total.label = Total Reactive Energy +thing-type.growatt.inverter.channel.erac-total.description = Total reactive energy supplied. +thing-type.growatt.inverter.channel.export-energy-today.label = Export Energy Today +thing-type.growatt.inverter.channel.export-energy-today.description = Energy exported to grid today. +thing-type.growatt.inverter.channel.export-energy-total.label = Export Energy Total +thing-type.growatt.inverter.channel.export-energy-total.description = Total energy exported to grid. +thing-type.growatt.inverter.channel.export-power.label = Export Power +thing-type.growatt.inverter.channel.export-power.description = Power exported to grid. +thing-type.growatt.inverter.channel.export-power-r.label = Export Power #R +thing-type.growatt.inverter.channel.export-power-r.description = Power exported to grid phase #R. +thing-type.growatt.inverter.channel.export-power-s.label = Export Power #S +thing-type.growatt.inverter.channel.export-power-s.description = Power exported to grid phase #S. +thing-type.growatt.inverter.channel.export-power-t.label = Export Power #T +thing-type.growatt.inverter.channel.export-power-t.description = Power exported to grid phase #T. +thing-type.growatt.inverter.channel.grid-frequency.label = Grid Frequency +thing-type.growatt.inverter.channel.grid-frequency.description = Frequency of the grid. +thing-type.growatt.inverter.channel.grid-voltage-r.label = Grid Voltage (#R) +thing-type.growatt.inverter.channel.grid-voltage-r.description = Voltage of the grid (phase #R). +thing-type.growatt.inverter.channel.grid-voltage-rs.label = Grid Voltage #RS +thing-type.growatt.inverter.channel.grid-voltage-rs.description = Voltage of the grid phases #RS. +thing-type.growatt.inverter.channel.grid-voltage-s.label = Grid Voltage #S +thing-type.growatt.inverter.channel.grid-voltage-s.description = Voltage of the grid phase #S. +thing-type.growatt.inverter.channel.grid-voltage-st.label = Grid Voltage #ST +thing-type.growatt.inverter.channel.grid-voltage-st.description = Voltage of the grid phases #ST. +thing-type.growatt.inverter.channel.grid-voltage-t.label = Grid Voltage #T +thing-type.growatt.inverter.channel.grid-voltage-t.description = Voltage of the grid phase #T. +thing-type.growatt.inverter.channel.grid-voltage-tr.label = Grid Voltage #TR +thing-type.growatt.inverter.channel.grid-voltage-tr.description = Voltage of the grid phases #TR. +thing-type.growatt.inverter.channel.import-charge-energy-today.label = Battery Import Energy Today +thing-type.growatt.inverter.channel.import-charge-energy-today.description = Energy imported from grid to charge battery today. +thing-type.growatt.inverter.channel.import-charge-energy-total.label = Battery Import Energy Totals +thing-type.growatt.inverter.channel.import-charge-energy-total.description = Total energy imported from grid to charge battery. +thing-type.growatt.inverter.channel.import-energy-today.label = Import Energy Today +thing-type.growatt.inverter.channel.import-energy-today.description = Energy imported from grid today. +thing-type.growatt.inverter.channel.import-energy-total.label = Import Energy Total +thing-type.growatt.inverter.channel.import-energy-total.description = Total energy imported from grid. +thing-type.growatt.inverter.channel.import-power.label = Import Power +thing-type.growatt.inverter.channel.import-power.description = Power imported. +thing-type.growatt.inverter.channel.import-power-r.label = Import Power #R +thing-type.growatt.inverter.channel.import-power-r.description = Power imported phase #R. +thing-type.growatt.inverter.channel.import-power-s.label = Import Power #S +thing-type.growatt.inverter.channel.import-power-s.description = Power imported phase #S. +thing-type.growatt.inverter.channel.import-power-t.label = Import Power #T +thing-type.growatt.inverter.channel.import-power-t.description = Power imported phase #T. +thing-type.growatt.inverter.channel.inverter-charge-energy-today.label = Battery Inverter Energy Today +thing-type.growatt.inverter.channel.inverter-charge-energy-today.description = Energy from inverter to charge battery today. +thing-type.growatt.inverter.channel.inverter-charge-energy-total.label = Battery Inverter Energy Total +thing-type.growatt.inverter.channel.inverter-charge-energy-total.description = Total energy from inverter to charge battery. +thing-type.growatt.inverter.channel.inverter-current-r.label = Inverter Current (#R) +thing-type.growatt.inverter.channel.inverter-current-r.description = AC current from inverter (phase #R). +thing-type.growatt.inverter.channel.inverter-current-s.label = Inverter Current #S +thing-type.growatt.inverter.channel.inverter-current-s.description = AC current from inverter phase #S. +thing-type.growatt.inverter.channel.inverter-current-t.label = Inverter Current #T +thing-type.growatt.inverter.channel.inverter-current-t.description = AC current from inverter phase #T. +thing-type.growatt.inverter.channel.inverter-energy-today.label = Inverter Energy Today +thing-type.growatt.inverter.channel.inverter-energy-today.description = Inverter output energy produced today. +thing-type.growatt.inverter.channel.inverter-energy-total.label = Inverter Energy Total +thing-type.growatt.inverter.channel.inverter-energy-total.description = Total inverter output energy produced. +thing-type.growatt.inverter.channel.inverter-power.label = Inverter Power +thing-type.growatt.inverter.channel.inverter-power.description = AC power the inverter (total). +thing-type.growatt.inverter.channel.inverter-power-r.label = Inverter Power (#R) +thing-type.growatt.inverter.channel.inverter-power-r.description = AC power from inverter (phase #R). +thing-type.growatt.inverter.channel.inverter-power-s.label = Inverter Power #S +thing-type.growatt.inverter.channel.inverter-power-s.description = AC power from inverter phase #S. +thing-type.growatt.inverter.channel.inverter-power-t.label = Inverter Power #T +thing-type.growatt.inverter.channel.inverter-power-t.description = AC power from inverter phase #T. +thing-type.growatt.inverter.channel.inverter-va.label = Inverter VA +thing-type.growatt.inverter.channel.inverter-va.description = AC VA produced by inverter. +thing-type.growatt.inverter.channel.load-energy-today.label = Load Energy Today +thing-type.growatt.inverter.channel.load-energy-today.description = Energy supplied to load today. +thing-type.growatt.inverter.channel.load-energy-total.label = Load Energy Total +thing-type.growatt.inverter.channel.load-energy-total.description = Total energy supplied to load. +thing-type.growatt.inverter.channel.load-percent.label = Load Percent +thing-type.growatt.inverter.channel.load-percent.description = Percent of full load. +thing-type.growatt.inverter.channel.load-power.label = Load Power +thing-type.growatt.inverter.channel.load-power.description = Power supplied to load. +thing-type.growatt.inverter.channel.load-power-r.label = Load Power #R +thing-type.growatt.inverter.channel.load-power-r.description = Power supplied to load phase #R. +thing-type.growatt.inverter.channel.load-power-s.label = Load Power #S +thing-type.growatt.inverter.channel.load-power-s.description = Power supplied to load phase #S. +thing-type.growatt.inverter.channel.load-power-t.label = Load Power #T +thing-type.growatt.inverter.channel.load-power-t.description = Power supplied to load phase #T. +thing-type.growatt.inverter.channel.n-bus-voltage.label = N Bus Voltage +thing-type.growatt.inverter.channel.n-bus-voltage.description = N Bus voltage. +thing-type.growatt.inverter.channel.p-bus-voltage.label = P Bus Voltage +thing-type.growatt.inverter.channel.p-bus-voltage.description = P Bus voltage. +thing-type.growatt.inverter.channel.pv-boost-temperature.label = Boost Temperature +thing-type.growatt.inverter.channel.pv-boost-temperature.description = Boost temperature. +thing-type.growatt.inverter.channel.pv-energy-today.label = DC Energy Today +thing-type.growatt.inverter.channel.pv-energy-today.description = Solar DC energy collected. +thing-type.growatt.inverter.channel.pv-energy-total.label = DC Energy Total +thing-type.growatt.inverter.channel.pv-energy-total.description = Total solar energy supplied to grid. +thing-type.growatt.inverter.channel.pv-ipm-temperature.label = Solar IPM Temperature +thing-type.growatt.inverter.channel.pv-ipm-temperature.description = Temperature of the IPM. +thing-type.growatt.inverter.channel.pv-power.label = Solar Input Power +thing-type.growatt.inverter.channel.pv-power.description = Power from solar panels. +thing-type.growatt.inverter.channel.pv-temperature.label = Solar Panel Temperature +thing-type.growatt.inverter.channel.pv-temperature.description = Temperature of the solar panels (string #1). +thing-type.growatt.inverter.channel.pv1-current.label = String #1 Current +thing-type.growatt.inverter.channel.pv1-current.description = Current from solar panel string #1. +thing-type.growatt.inverter.channel.pv1-energy-today.label = DC Energy #1 Today +thing-type.growatt.inverter.channel.pv1-energy-today.description = Solar DC energy collected by string #1 to grid today. +thing-type.growatt.inverter.channel.pv1-energy-total.label = DC Energy #1 Total +thing-type.growatt.inverter.channel.pv1-energy-total.description = Total solar DC collected by string #1. +thing-type.growatt.inverter.channel.pv1-power.label = String #1 Power +thing-type.growatt.inverter.channel.pv1-power.description = Power from solar panel string #1. +thing-type.growatt.inverter.channel.pv1-voltage.label = String #1 Voltage +thing-type.growatt.inverter.channel.pv1-voltage.description = Voltage from solar panel string #1. +thing-type.growatt.inverter.channel.pv2-current.label = String #2 Current +thing-type.growatt.inverter.channel.pv2-current.description = Current from solar panel string #2. +thing-type.growatt.inverter.channel.pv2-energy-today.label = DC Energy #2 Today +thing-type.growatt.inverter.channel.pv2-energy-today.description = Solar DC energy collected by string #2 to grid today. +thing-type.growatt.inverter.channel.pv2-energy-total.label = DC Energy #2 Total +thing-type.growatt.inverter.channel.pv2-energy-total.description = Total solar DC collected by string #2. +thing-type.growatt.inverter.channel.pv2-power.label = String #2 Power +thing-type.growatt.inverter.channel.pv2-power.description = Power from solar panel string #2. +thing-type.growatt.inverter.channel.pv2-temperature.label = Solar Panel Temperature #2 +thing-type.growatt.inverter.channel.pv2-temperature.description = Temperature of the solar panels (string #2). +thing-type.growatt.inverter.channel.pv2-voltage.label = String #2 Voltage +thing-type.growatt.inverter.channel.pv2-voltage.description = Voltage from solar panel string #2. +thing-type.growatt.inverter.channel.rac.label = Reactive Power +thing-type.growatt.inverter.channel.rac.description = Reactive power output. +thing-type.growatt.inverter.channel.sp-bus-voltage.label = SP Bus Voltage +thing-type.growatt.inverter.channel.sp-bus-voltage.description = SP Bus voltage. +thing-type.growatt.inverter.channel.sp-display-status.label = Solar Panel Display +thing-type.growatt.inverter.channel.sp-display-status.description = Solar panel display status code. +thing-type.growatt.inverter.channel.system-fault-0.label = Fault Code #0 +thing-type.growatt.inverter.channel.system-fault-0.description = System fault code #0. +thing-type.growatt.inverter.channel.system-fault-1.label = Fault Code #1 +thing-type.growatt.inverter.channel.system-fault-1.description = System fault code #1. +thing-type.growatt.inverter.channel.system-fault-2.label = Fault Code #2 +thing-type.growatt.inverter.channel.system-fault-2.description = System fault code #2. +thing-type.growatt.inverter.channel.system-fault-3.label = Fault Code #3 +thing-type.growatt.inverter.channel.system-fault-3.description = System fault code #3. +thing-type.growatt.inverter.channel.system-fault-4.label = Fault Code #4 +thing-type.growatt.inverter.channel.system-fault-4.description = System fault code #4. +thing-type.growatt.inverter.channel.system-fault-5.label = Fault Code #5 +thing-type.growatt.inverter.channel.system-fault-5.description = System fault code #5. +thing-type.growatt.inverter.channel.system-fault-6.label = Fault Code #6 +thing-type.growatt.inverter.channel.system-fault-6.description = System fault code #6. +thing-type.growatt.inverter.channel.system-fault-7.label = Fault Code #7 +thing-type.growatt.inverter.channel.system-fault-7.description = System fault code #7. +thing-type.growatt.inverter.channel.system-status.label = Inverter Status +thing-type.growatt.inverter.channel.system-status.description = Status code of the inverter. +thing-type.growatt.inverter.channel.system-work-mode.label = System Work Mode +thing-type.growatt.inverter.channel.system-work-mode.description = System work mode code. +thing-type.growatt.inverter.channel.temperature-4.label = Temperature #4 +thing-type.growatt.inverter.channel.temperature-4.description = Temperature #4. +thing-type.growatt.inverter.channel.total-work-time.label = Total Working Time +thing-type.growatt.inverter.channel.total-work-time.description = Total inverter working time. + +# thing types config + +thing-type.config.growatt.bridge.password.label = Password +thing-type.config.growatt.bridge.password.description = Password to login to the Shine App. +thing-type.config.growatt.bridge.userName.label = User Name +thing-type.config.growatt.bridge.userName.description = User name to login to the Shine App. +thing-type.config.growatt.inverter.deviceId.label = Device Id +thing-type.config.growatt.inverter.deviceId.description = Id (serial number) of the inverter. + +# channel types + +channel-type.growatt.advanced-electric-current.label = Electric Current +channel-type.growatt.advanced-electric-energy.label = Electric Energy +channel-type.growatt.advanced-electric-frequency.label = Electric Frequency +channel-type.growatt.advanced-electric-kvarh.label = Electric Reactive Energy +channel-type.growatt.advanced-electric-power.label = Electric Power +channel-type.growatt.advanced-electric-va.label = Electric VA +channel-type.growatt.advanced-electric-var.label = Electric Reactive Power +channel-type.growatt.advanced-electric-voltage.label = Electric Voltage +channel-type.growatt.advanced-fault-code.label = Fault Code +channel-type.growatt.advanced-outdoor-temperature.label = Outdoor Temperature +channel-type.growatt.advanced-percent.label = Percentage +channel-type.growatt.advanced-status-code.label = Status Code +channel-type.growatt.advanced-work-time.label = Work Time +channel-type.growatt.system-status-code.label = Status Code + +# discovery + +discovery.growatt-inverter = Growatt Inverter + +# thing status + +status.awaiting-data = Waiting for data from Grott application +status.awaiting-data-timeout = Timed out waiting for data from Grott application + +# actions + +actions.battery-program.label = Setup Battery Program +actions.battery-program.description = Setup the battery charging / discharging program +actions.enable-ac-charging.label = Enable AC Charging +actions.enable-ac-charging.description = Enable the battery to be charged from AC supply +actions.enable-program.label = Program Enable +actions.enable-program.description = Enable / disable the battery charging / discharging program +actions.power-level.label = Charge / Discharge Power +actions.power-level.description = The rate of battery charging / discharging (1%..100%) +actions.program-mode.label = Battery Program Mode +actions.program-mode.description = Select Load First (0), Battery First (2), Grid First (2) +actions.start-time.label = Start Time +actions.start-time.description = The time when the program shall start +actions.stop-soc.label = Stop SOC Level +actions.stop-soc.description = The battery SOC target for when the program is completed +actions.stop-time.label = Stop Time +actions.stop-time.description = The time when the program shall stop diff --git a/bundles/org.openhab.binding.growatt/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.growatt/src/main/resources/OH-INF/thing/thing-types.xml new file mode 100644 index 00000000000..a736a13e8fd --- /dev/null +++ b/bundles/org.openhab.binding.growatt/src/main/resources/OH-INF/thing/thing-types.xml @@ -0,0 +1,551 @@ + + + + + + + Bridge Thing for Growatt Binding + + + + + User name to login to the Shine App. + true + + + password + + Password to login to the Shine App. + true + + + + + + + + + + + + Inverter Thing for Growatt Binding + + + + + + Status code of the inverter. + + + + + + Power from solar panels. + + + + + + Voltage from solar panel string #1. + + + + Voltage from solar panel string #2. + + + + + Current from solar panel string #1. + + + + Current from solar panel string #2. + + + + + Power from solar panel string #1. + + + + Power from solar panel string #2. + + + + + + Frequency of the grid. + + + + Voltage of the grid (phase #R). + + + + Voltage of the grid phase #S. + + + + Voltage of the grid phase #T. + + + + Voltage of the grid phases #RS. + + + + Voltage of the grid phases #ST. + + + + Voltage of the grid phases #TR. + + + + + + AC current from inverter (phase #R). + + + + AC current from inverter phase #S. + + + + AC current from inverter phase #T. + + + + + AC power the inverter (total). + + + + AC power from inverter (phase #R). + + + + AC power from inverter phase #S. + + + + AC power from inverter phase #T. + + + + + AC VA produced by inverter. + + + + + + Charge power to battery. + + + + Charge current to battery. + + + + Discharge power from battery. + + + + Discharge VA from battery. + + + + + + Power exported to grid. + + + + Power exported to grid phase #R. + + + + Power exported to grid phase #S. + + + + Power exported to grid phase #T. + + + + + + Power imported. + + + + Power imported phase #R. + + + + Power imported phase #S. + + + + Power imported phase #T. + + + + + + Power supplied to load. + + + + Power supplied to load phase #R. + + + + Power supplied to load phase #S. + + + + Power supplied to load phase #T. + + + + + + Inverter output energy produced today. + + + + Total inverter output energy produced. + + + + + + Solar DC energy collected. + + + + Solar DC energy collected by string #1 to grid today. + + + + Solar DC energy collected by string #2 to grid today. + + + + + Total solar energy supplied to grid. + + + + Total solar DC collected by string #1. + + + + Total solar DC collected by string #2. + + + + + + Energy exported to grid today. + + + + Total energy exported to grid. + + + + + + Energy imported from grid today. + + + + Total energy imported from grid. + + + + + + Energy supplied to load today. + + + + Total energy supplied to load. + + + + + + Energy imported from grid to charge battery today. + + + + Total energy imported from grid to charge battery. + + + + + + Energy from inverter to charge battery today. + + + + Total energy from inverter to charge battery. + + + + + + Energy consumed from battery today. + + + + Total energy consumed from battery. + + + + + + Total inverter working time. + + + + + + P Bus voltage. + + + + N Bus voltage. + + + + SP Bus voltage. + + + + + + Temperature of the solar panels (string #1). + + + + Temperature of the IPM. + + + + Boost temperature. + + + + Temperature #4. + + + + Temperature of the solar panels (string #2). + + + + + + Type code of the battery. + + + + Battery temperature. + + + + Battery voltage. + + + + Battery display code. + + + + Battery state of charge. + + + + + + System fault code #0. + + + + System fault code #1. + + + + System fault code #2. + + + + System fault code #3. + + + + System fault code #4. + + + + System fault code #5. + + + + System fault code #6. + + + + System fault code #7. + + + + + + System work mode code. + + + + Solar panel display status code. + + + + Constant power OK code. + + + + Percent of full load. + + + + + + Reactive power output. + + + + Reactive energy supplied today. + + + + Total reactive energy supplied. + + + + + + + + Id (serial number) of the inverter. + + + + + + + + Number:Dimensionless + + Status + + + + + Number:Dimensionless + + Status + + + + + Number:Dimensionless + + Siren + + + + + Number:Dimensionless + + + + + + Number:Frequency + + Energy + + + + + Number:Power + + Energy + + + + + Number:Time + + Time + + + + + Number:Power + + Energy + + + + + Number:ElectricCurrent + + Energy + + + + + Number:ElectricPotential + + Energy + + + + + Number:Energy + + Energy + + + + + Number:Power + + Energy + + + + + Number:Energy + + Energy + + + + + Number:Temperature + + Temperature + + + + diff --git a/bundles/org.openhab.binding.growatt/src/test/java/org/openhab/binding/growatt/test/GrowattTest.java b/bundles/org.openhab.binding.growatt/src/test/java/org/openhab/binding/growatt/test/GrowattTest.java new file mode 100644 index 00000000000..474092ae622 --- /dev/null +++ b/bundles/org.openhab.binding.growatt/src/test/java/org/openhab/binding/growatt/test/GrowattTest.java @@ -0,0 +1,386 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.growatt.test; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +import java.io.BufferedReader; +import java.io.FileNotFoundException; +import java.io.FileReader; +import java.io.IOException; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.stream.Collectors; + +import javax.net.ssl.SSLSession; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.util.ssl.SslContextFactory; +import org.junit.jupiter.api.Test; +import org.openhab.binding.growatt.internal.GrowattChannels; +import org.openhab.binding.growatt.internal.GrowattChannels.UoM; +import org.openhab.binding.growatt.internal.cloud.GrowattCloud; +import org.openhab.binding.growatt.internal.config.GrowattBridgeConfiguration; +import org.openhab.binding.growatt.internal.dto.GrottDevice; +import org.openhab.binding.growatt.internal.dto.GrottValues; +import org.openhab.binding.growatt.internal.dto.helper.GrottIntegerDeserializer; +import org.openhab.binding.growatt.internal.dto.helper.GrottValuesHelper; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.unit.SIUnits; +import org.openhab.core.library.unit.Units; +import org.openhab.core.types.State; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +/** + * The {@link GrowattTest} is a JUnit test suite for the Growatt binding. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class GrowattTest { + + private final Gson gson = new GsonBuilder().registerTypeAdapter(Integer.class, new GrottIntegerDeserializer()) + .create(); + + /** + * Load a (JSON) string from a file + * + * @throws IOException + * @throws FileNotFoundException + */ + private String load(String fileName) throws FileNotFoundException, IOException { + try (FileReader file = new FileReader(String.format("src/test/resources/%s.json", fileName)); + BufferedReader reader = new BufferedReader(file)) { + StringBuilder builder = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + builder.append(line).append("\n"); + } + return builder.toString(); + } + } + + /** + * Load a GrottValues class from a JSON payload. + * + * @param fileName the file containing the JSON payload. + * @return a GrottValues DTO. + * @throws IOException + * @throws FileNotFoundException + */ + private GrottValues loadGrottValues(String fileName) throws FileNotFoundException, IOException { + String json = load(fileName); + GrottDevice device = gson.fromJson(json, GrottDevice.class); + assertNotNull(device); + GrottValues grottValues = device.getValues(); + assertNotNull(grottValues); + return grottValues; + } + + @Test + void testGrottValuesAccessibility() throws FileNotFoundException, IOException { + testGrottValuesAccessibility("simple"); + testGrottValuesAccessibility("sph"); + } + + /** + * For the given JSON file, test that GrottValues implements the same fields as the Map returned from + * GrowattChannels.getMap(). Test that all fields can be accessed and that they are either null or an Integer + * instance. + * + * @param fileName the name of the JSON file to be tested. + * @throws IOException + * @throws FileNotFoundException + */ + private void testGrottValuesAccessibility(String fileName) throws FileNotFoundException, IOException { + GrottValues grottValues = loadGrottValues(fileName); + + List fields = Arrays.asList(GrottValues.class.getFields()).stream().map(f -> f.getName()) + .collect(Collectors.toList()); + + // test that the GrottValues DTO has identical field names to the CHANNEL_ID_UOM_MAP channel ids + for (String channel : GrowattChannels.getMap().keySet()) { + assertTrue(fields.contains(GrottValues.getFieldName(channel))); + } + + // test that the CHANNEL_ID_UOM_MAP has identical channel ids to the GrottValues DTO field names + for (String field : fields) { + assertTrue(GrowattChannels.getMap().containsKey(GrottValues.getChannelId(field))); + } + + // test that the CHANNEL_ID_UOM_MAP and the GrottValues DTO have the same number of fields resp. channel ids + assertEquals(fields.size(), GrowattChannels.getMap().size()); + List errors = new ArrayList<>(); + + for (Entry entry : GrowattChannels.getMap().entrySet()) { + String channelId = entry.getKey(); + Field field; + // test that the field can be accessed + try { + field = GrottValues.class.getField(GrottValues.getFieldName(channelId)); + } catch (NoSuchFieldException | SecurityException e) { + String msg = e.getMessage(); + errors.add(msg != null ? msg : e.getClass().getName()); + continue; + } + // test that the field value is either null or an Integer + try { + Object value = field.get(grottValues); + assertTrue(value == null || (value instanceof Integer)); + } catch (IllegalArgumentException | IllegalAccessException e) { + String msg = e.getMessage(); + errors.add(msg != null ? msg : e.getClass().getName()); + continue; + } + } + if (!errors.isEmpty()) { + fail(errors.toString()); + } + } + + /** + * Spot checks to test that GrottValues is loaded with the correct contents from the "simple" JSON file. + * + * @throws IOException + * @throws FileNotFoundException + * @throws IllegalArgumentException + * @throws IllegalAccessException + * @throws SecurityException + * @throws NoSuchFieldException + */ + @Test + void testGrottValuesContents() throws FileNotFoundException, IOException, NoSuchFieldException, SecurityException, + IllegalAccessException, IllegalArgumentException { + GrottValues grottValues = loadGrottValues("simple"); + + assertEquals(1, grottValues.system_status); + assertEquals(1622, grottValues.pv_power); + assertEquals(4997, grottValues.grid_frequency); + assertEquals(2353, grottValues.grid_voltage_r); + assertEquals(7, grottValues.inverter_current_r); + assertEquals(1460, grottValues.inverter_power); + assertEquals(1460, grottValues.inverter_power_r); + assertEquals(273, grottValues.pv_temperature); + assertEquals(87, grottValues.inverter_energy_today); + assertEquals(43265, grottValues.inverter_energy_total); + assertEquals(90, grottValues.pv1_energy_today); + assertEquals(45453, grottValues.pv1_energy_total); + assertEquals(45453, grottValues.pv_energy_total); + assertEquals(0, grottValues.pv2_voltage); + assertEquals(0, grottValues.pv2_current); + assertEquals(0, grottValues.pv2_power); + assertEquals(65503878, grottValues.total_work_time); + + Map> channelStates = null; + channelStates = GrottValuesHelper.getChannelStates(grottValues); + + assertNotNull(channelStates); + assertEquals(29, channelStates.size()); + + channelStates.forEach((channelId, state) -> { + assertTrue(state instanceof QuantityType); + }); + + assertEquals(QuantityType.ONE, channelStates.get("system-status")); + assertEquals(QuantityType.valueOf(162.2, Units.WATT), channelStates.get("pv-power")); + assertEquals(QuantityType.valueOf(49.97, Units.HERTZ), channelStates.get("grid-frequency")); + assertEquals(QuantityType.valueOf(235.3, Units.VOLT), channelStates.get("grid-voltage-r")); + assertEquals(QuantityType.valueOf(0.7, Units.AMPERE), channelStates.get("inverter-current-r")); + assertEquals(QuantityType.valueOf(146, Units.WATT), channelStates.get("inverter-power")); + assertEquals(QuantityType.valueOf(146, Units.WATT), channelStates.get("inverter-power-r")); + assertEquals(QuantityType.valueOf(27.3, SIUnits.CELSIUS), channelStates.get("pv-temperature")); + assertEquals(QuantityType.valueOf(8.7, Units.KILOWATT_HOUR), channelStates.get("inverter-energy-today")); + assertEquals(QuantityType.valueOf(4326.5, Units.KILOWATT_HOUR), channelStates.get("inverter-energy-total")); + assertEquals(QuantityType.valueOf(9, Units.KILOWATT_HOUR), channelStates.get("pv1-energy-today")); + assertEquals(QuantityType.valueOf(4545.3, Units.KILOWATT_HOUR), channelStates.get("pv1-energy-total")); + assertEquals(QuantityType.valueOf(4545.3, Units.KILOWATT_HOUR), channelStates.get("pv-energy-total")); + assertEquals(QuantityType.valueOf(0, Units.VOLT), channelStates.get("pv2-voltage")); + assertEquals(QuantityType.valueOf(0, Units.AMPERE), channelStates.get("pv2-current")); + assertEquals(QuantityType.valueOf(0, Units.WATT), channelStates.get("pv2-power")); + State state = channelStates.get("total-work-time"); + assertTrue(state instanceof QuantityType); + if (state instanceof QuantityType) { + QuantityType seconds = ((QuantityType) state).toUnit(Units.SECOND); + assertNotNull(seconds); + assertEquals(QuantityType.valueOf(32751939, Units.SECOND).doubleValue(), seconds.doubleValue(), 0.1); + } + + assertNull(channelStates.get("aardvark")); + } + + @Test + void testJsonFieldsMappedToDto() throws FileNotFoundException, IOException { + testJsonFieldsMappedToDto("simple"); + testJsonFieldsMappedToDto("sph"); + } + + /** + * For the given JSON test file name, check that each field in its JSON is mapped to precisely one field in the + * values DTO. + * + * @param fileName the name of the JSON file to be tested. + * @throws IOException + * @throws FileNotFoundException + */ + private void testJsonFieldsMappedToDto(String fileName) throws FileNotFoundException, IOException { + Field[] fields = GrottValues.class.getFields(); + String json = load(fileName); + JsonParser.parseString(json).getAsJsonObject().get("values").getAsJsonObject().entrySet().forEach(e -> { + String key = e.getKey(); + if (!"datalogserial".equals(key) && !"pvserial".equals(key)) { + JsonObject testJsonObject = new JsonObject(); + testJsonObject.add(key, e.getValue()); + GrottValues testDto = gson.fromJson(testJsonObject, GrottValues.class); + int mappedFieldCount = 0; + List errors = new ArrayList<>(); + for (Field field : fields) { + try { + if (field.get(testDto) != null) { + mappedFieldCount++; + } + } catch (IllegalAccessException | IllegalArgumentException ex) { + String msg = ex.getMessage(); + errors.add(msg != null ? msg : ex.getClass().getName()); + } + } + if (!errors.isEmpty()) { + fail(errors.toString()); + } + assertEquals(1, mappedFieldCount); + } + }); + } + + /** + * Test the Growatt remote cloud API server. + * Will not run unless actual user credentials are provided. + * + * @throws Exception + */ + @Test + void testServer() throws Exception { + GrowattBridgeConfiguration configuration = new GrowattBridgeConfiguration(); + String deviceId = ""; + + /* + * To test on an actual inverter, populate its plant data and user credentials below. + * + * configuration.userName = "aa"; + * configuration.password ="bb"; + * deviceId = "cc"; + * + */ + + if (configuration.userName == null) { + return; + } + + SslContextFactory.Client sslContextFactory = new SslContextFactory.Client.Client(); + sslContextFactory.setHostnameVerifier((@Nullable String host, @Nullable SSLSession session) -> true); + sslContextFactory.setValidatePeerCerts(false); + + HttpClientFactory httpClientFactory = mock(HttpClientFactory.class); + when(httpClientFactory.createHttpClient(anyString())).thenReturn(new HttpClient(sslContextFactory)); + + try (GrowattCloud api = new GrowattCloud(configuration, httpClientFactory)) { + Integer programMode = GrowattCloud.ProgramMode.BATTERY_FIRST.ordinal(); + Integer chargingPower = 97; + Integer targetSOC = 23; + Boolean allowAcCharging = false; + String startTime = "01:16"; + String stopTime = "02:17"; + Boolean programEnable = false; + api.setupBatteryProgram(deviceId, programMode, chargingPower, targetSOC, allowAcCharging, startTime, + stopTime, programEnable); + Map result = api.getDeviceSettings(deviceId); + assertFalse(result.isEmpty()); + assertEquals(chargingPower, GrowattCloud.mapGetInteger(result, GrowattCloud.CHARGE_PROGRAM_POWER)); + assertEquals(targetSOC, GrowattCloud.mapGetInteger(result, GrowattCloud.CHARGE_PROGRAM_TARGET_SOC)); + assertEquals(allowAcCharging, + GrowattCloud.mapGetBoolean(result, GrowattCloud.CHARGE_PROGRAM_ALLOW_AC_CHARGING)); + assertEquals(GrowattCloud.localTimeOf(startTime), + GrowattCloud.mapGetLocalTime(result, GrowattCloud.CHARGE_PROGRAM_START_TIME)); + assertEquals(GrowattCloud.localTimeOf(stopTime), + GrowattCloud.mapGetLocalTime(result, GrowattCloud.CHARGE_PROGRAM_STOP_TIME)); + assertEquals(programEnable, GrowattCloud.mapGetBoolean(result, GrowattCloud.CHARGE_PROGRAM_ENABLE)); + + chargingPower = 100; + targetSOC = 20; + allowAcCharging = true; + startTime = "00:15"; + stopTime = "06:45"; + programEnable = true; + api.setupBatteryProgram(deviceId, programMode, chargingPower, targetSOC, allowAcCharging, startTime, + stopTime, programEnable); + result = api.getDeviceSettings(deviceId); + assertFalse(result.isEmpty()); + assertEquals(chargingPower, GrowattCloud.mapGetInteger(result, GrowattCloud.CHARGE_PROGRAM_POWER)); + assertEquals(targetSOC, GrowattCloud.mapGetInteger(result, GrowattCloud.CHARGE_PROGRAM_TARGET_SOC)); + assertEquals(allowAcCharging, + GrowattCloud.mapGetBoolean(result, GrowattCloud.CHARGE_PROGRAM_ALLOW_AC_CHARGING)); + assertEquals(GrowattCloud.localTimeOf(startTime), + GrowattCloud.mapGetLocalTime(result, GrowattCloud.CHARGE_PROGRAM_START_TIME)); + assertEquals(GrowattCloud.localTimeOf(stopTime), + GrowattCloud.mapGetLocalTime(result, GrowattCloud.CHARGE_PROGRAM_STOP_TIME)); + assertEquals(programEnable, GrowattCloud.mapGetBoolean(result, GrowattCloud.CHARGE_PROGRAM_ENABLE)); + } + } + + @Test + void testThreePhaseGrottValuesContents() throws FileNotFoundException, IOException, NoSuchFieldException, + SecurityException, IllegalAccessException, IllegalArgumentException { + GrottValues grottValues = loadGrottValues("3phase"); + assertNotNull(grottValues); + + Map> channelStates = GrottValuesHelper.getChannelStates(grottValues); + assertNotNull(channelStates); + assertEquals(64, channelStates.size()); + + assertEquals(QuantityType.valueOf(-36.5, Units.WATT), channelStates.get("inverter-power")); + assertEquals(QuantityType.valueOf(11, Units.PERCENT), channelStates.get("battery-soc")); + assertEquals(QuantityType.valueOf(408.4, Units.VOLT), channelStates.get("grid-voltage-rs")); + assertEquals(QuantityType.valueOf(326.5, Units.VOLT), channelStates.get("n-bus-voltage")); + assertEquals(QuantityType.valueOf(404.1, Units.VOLT), channelStates.get("battery-voltage")); + } + + @Test + void testMeterGrottValuesContents() throws FileNotFoundException, IOException, NoSuchFieldException, + SecurityException, IllegalAccessException, IllegalArgumentException { + GrottValues grottValues = loadGrottValues("meter"); + assertNotNull(grottValues); + + Map> channelStates = GrottValuesHelper.getChannelStates(grottValues); + assertNotNull(channelStates); + assertEquals(16, channelStates.size()); + + assertEquals(QuantityType.valueOf(809.8, Units.WATT), channelStates.get("import-power")); + assertEquals(QuantityType.valueOf(171.0, Units.WATT), channelStates.get("import-power-s")); + assertEquals(QuantityType.valueOf(237.4, Units.VOLT), channelStates.get("grid-voltage-s")); + assertEquals(QuantityType.valueOf(409.5, Units.VOLT), channelStates.get("grid-voltage-rs")); + assertEquals(QuantityType.valueOf(1.5, Units.AMPERE), channelStates.get("inverter-current-s")); + } +} diff --git a/bundles/org.openhab.binding.growatt/src/test/resources/3phase.json b/bundles/org.openhab.binding.growatt/src/test/resources/3phase.json new file mode 100644 index 00000000000..ee5371721d2 --- /dev/null +++ b/bundles/org.openhab.binding.growatt/src/test/resources/3phase.json @@ -0,0 +1,158 @@ +{ + "device": "KLN0D6L034", + "time": "2023-12-26T21:48:33", + "buffered": "no", + "values": { + "pvserial": "KLN0D6L034", + "pvstatus": 1, + "pvpowerin": 0, + "pv1voltage": 669, + "pv1current": 0, + "pv1watt": 0, + "pv2voltage": 695, + "pv2current": 0, + "pv2watt": 0, + "pv3voltage": 0, + "pv3current": 0, + "pv3watt": 0, + "pv4voltage": 0, + "pv4current": 0, + "pv4watt": 0, + "pvpowerout": 4294966929, + "pvfrequentie": 5001, + "pvgridvoltage": 2359, + "pvgridcurrent": 7, + "pvgridpower": 1651, + "pvgridvoltage2": 2367, + "pvgridcurrent2": 8, + "pvgridpower2": 1893, + "pvgridvoltage3": 2379, + "pvgridcurrent3": 8, + "pvgridpower3": 1903, + "vacrs": 4084, + "vacst": 4118, + "vactr": 4104, + "ptousertotal": 8099, + "ptogridtotal": 0, + "ptoloadtotal": 8239, + "totworktime": 79652, + "pvenergytoday": 178, + "pvenergytotal": 178, + "epvtotal ": 162, + "epv1today ": 79, + "epv1total": 79, + "epv2today": 83, + "epv2total": 83, + "epv3today": 0, + "epv3total": 0, + "etousertoday": 64, + "etousertotal": 64, + "etogridtoday": 1, + "etogridtotal": 1, + "eloadtoday": 237, + "eloadtotal": 0, + "deratingmode": 0, + "iso": 15997, + "dcir": 0, + "dcis": 0, + "dcit": 0, + "gfci": 137645, + "pvtemperature": 296, + "pvipmtemperature": 410, + "temp3": 296, + "temp4": 0, + "temp5": 319, + "pbusvoltage": 3311, + "nbusvoltage": 3265, + "ipf": 20000, + "realoppercent": 0, + "opfullwatt": 150000, + "standbyflag": 0, + "faultcode": 0, + "warningcode": 0, + "systemfaultword0": 0, + "systemfaultword1": 0, + "systemfaultword2": 0, + "systemfaultword3": 0, + "systemfaultword4": 0, + "systemfaultword5": 0, + "systemfaultword6": 0, + "systemfaultword7": 0, + "invstartdelaytime": 60, + "bdconoffstate": 1, + "drycontactstate": 0, + "edischrtoday": 103, + "edischrtotal": 1843, + "echrtoday": 91, + "echrtotal": 3005, + "eacchrtoday": 5, + "eacchrtotal": 5, + "priority": 1, + "epsfac": 0, + "epsvac1": 0, + "epsiac1": 0, + "epspac1": 0, + "epsvac2": 0, + "epsiac2": 0, + "epspac2": 0, + "epsvac3": 0, + "epsiac3": 0, + "epspac3": 0, + "epspac": 0, + "loadpercent": 0, + "pf": 10000, + "dcv": 0, + "bdc1_sysstatemode": 513, + "bdc1_faultcode": 0, + "bdc1_warncode": 701, + "bdc1_vbat": 6582, + "bdc1_ibat": 0, + "bdc1_soc": 11, + "bdc1_vbus1": 6582, + "bdc1_vbus2": 3303, + "bdc1_ibb": 0, + "bdc1_illc": 0, + "bdc1_tempa": 409, + "bdc1_tempb": 291, + "bdc1_pdischr": 100, + "bdc1_pchr": 0, + "bdc1_edischrtotal": 1843, + "bdc1_echrtotal": 3005, + "bdc1_flag": 1, + "bdc2_sysstatemode": 17, + "bdc2_faultcode": 12, + "bdc2_warncode": 248, + "bdc2_vbat": 266, + "bdc2_ibat": 223, + "bdc2_soc": 19, + "bdc2_vbus1": 49, + "bdc2_vbus2": 11, + "bdc2_ibb": 11, + "bdc2_illc": 4, + "bdc2_tempa": 0, + "bdc2_tempb": 394, + "bdc2_pdischr": 26214400, + "bdc2_pchr": 0, + "bdc2_edischrtotal": 0, + "bdc2_echrtotal": 0, + "bdc2_flag": 0, + "bms_status": 4, + "bms_error": 0, + "bms_warninfo": 0, + "bms_soc": 11, + "bms_batteryvolt": 4041, + "bms_batterycurr": 0, + "bms_batterytemp": 0, + "bms_maxcurr": 2200, + "bms_deltavolt": 2200, + "bms_cyclecnt": 0, + "bms_soh": 100, + "bms_constantvolt": 568, + "bms_bms_info": 464, + "bms_packinfo": 0, + "bms_usingcap": 0, + "bms_fw": 1400, + "bms_mcuversion": 0, + "bms_commtype": 1 + } +} diff --git a/bundles/org.openhab.binding.growatt/src/test/resources/meter.json b/bundles/org.openhab.binding.growatt/src/test/resources/meter.json new file mode 100644 index 00000000000..5388464ac96 --- /dev/null +++ b/bundles/org.openhab.binding.growatt/src/test/resources/meter.json @@ -0,0 +1,39 @@ +{ + "device": "GZL0DA804M", + "time": "2023-12-26T21:48:36", + "buffered": "no", + "values": { + "datalogserial": "GZL0DA804M", + "pvserial": "KLN0D6L034", + "voltage_l1": 2355, + "voltage_l2": 2374, + "voltage_l3": 2376, + "Current_l1": 22, + "Current_l2": 15, + "Current_l3": 12, + "act_power_l1": 4909, + "act_power_l2": 1710, + "act_power_l3": 1478, + "app_power_l1": 5058, + "app_power_l2": 3321, + "app_power_l3": 2712, + "react_power_l1": -1222, + "react_power_l2": -2847, + "react_power_l3": -2275, + "powerfactor_l1": 936, + "powerfactor_l2": 481, + "powerfactor_l3": 502, + "pos_rev_act_power": 8098, + "pos_act_power": 8098, + "rev_act_power": 8098, + "app_power": 11091, + "react_power": -6346, + "powerfactor": 690, + "frequency": 500, + "L1-2_voltage": 4095, + "L2-3_voltage": 4113, + "L3-1_voltage": 4097, + "pos_act_energy": 9587, + "rev_act_energy": 1387 + } +} diff --git a/bundles/org.openhab.binding.growatt/src/test/resources/simple.json b/bundles/org.openhab.binding.growatt/src/test/resources/simple.json new file mode 100644 index 00000000000..22b5e4f1438 --- /dev/null +++ b/bundles/org.openhab.binding.growatt/src/test/resources/simple.json @@ -0,0 +1,36 @@ +{ + "device": "INVERTID", + "time": "2021-02-13T16:34:28", + "buffered": "no", + "values": { + "pvstatus": 1, + "pvpowerin": 1622, + "pv1voltage": 2475, + "pv1current": 6, + "pv1watt": 1622, + "pv2voltage": 0, + "pv2current": 0, + "pv2watt": 0, + "pvpowerout": 1460, + "pvfrequentie": 4997, + "pvgridvoltage": 2353, + "pvgridcurrent": 7, + "pvgridpower": 1460, + "pvgridvoltage2": 0, + "pvgridcurrent2": 0, + "pvgridpower2": 0, + "pvgridvoltage3": 0, + "pvgridcurrent3": 0, + "pvgridpower3": 0, + "pvenergytoday": 87, + "pvenergytotal": 43265, + "totworktime": 65503878, + "pvtemperature": 273, + "pvipmtemperature": 0, + "epv1today": 90, + "epv1total": 45453, + "epv2today": 0, + "epv2total": 0, + "epvtotal": 45453 + } +} diff --git a/bundles/org.openhab.binding.growatt/src/test/resources/sph.json b/bundles/org.openhab.binding.growatt/src/test/resources/sph.json new file mode 100644 index 00000000000..a6ce013394a --- /dev/null +++ b/bundles/org.openhab.binding.growatt/src/test/resources/sph.json @@ -0,0 +1,75 @@ +{ + "device": "KUM0CLU03Y", + "time": "2023-09-10T12:23:13", + "buffered": "no", + "values": { + "datalogserial": "GPG0DBJ05N", + "pvserial": "KUM0CLU03Y", + "pvstatus": 5, + "pvpowerin": 16100, + "pv1voltage": 1805, + "pv1current": 89, + "pv1watt": 16169, + "pv2voltage": 0, + "pv2current": 0, + "pv2watt": 0, + "pvpowerout": 4285, + "pvfrequentie": 5003, + "pvgridvoltage": 2443, + "pvgridcurrent": 18, + "pvgridpower": 4609, + "pvgridvoltage2": 0, + "pvgridcurrent2": 0, + "pvgridpower2": 0, + "pvgridvoltage3": 0, + "pvgridcurrent3": 0, + "pvgridpower3": 0, + "totworktime": 6723587, + "eactoday": 27, + "pvenergytoday": 27, + "eactotal": 3571, + "epvtotal": 4105, + "epv1today": 64, + "epv1total": 4057, + "epv2today": 0, + "epv2total": 0, + "pvtemperature": 576, + "pvipmtemperature": 527, + "pvboosttemp": 572, + "bat_dsp": 541, + "eacharge_today": 10, + "eacharge_total": 277, + "batterytype": 1, + "uwsysworkmode": 5, + "systemfaultword0": 0, + "systemfaultword1": 0, + "systemfaultword2": 0, + "systemfaultword3": 0, + "systemfaultword4": 0, + "systemfaultword5": 0, + "systemfaultword6": 0, + "systemfaultword7": 0, + "pdischarge1": 0, + "p1charge1": 10284, + "vbat": 539, + "SOC": 69, + "pactouserr": 0, + "pactousertot": 0, + "pactogridr": 0, + "pactogridtot": 0, + "plocaloadr": 5800, + "plocaloadtot": 5800, + "spdspstatus": 5, + "spbusvolt": 3290, + "etouser_tod": 54, + "etouser_tot": 2330, + "etogrid_tod": 1, + "etogrid_tot": 707, + "edischarge1_tod": 3, + "edischarge1_tot": 1652, + "eharge1_tod": 41, + "eharge1_tot": 1524, + "elocalload_tod": 80, + "elocalload_tot": 5856 + } +} diff --git a/bundles/pom.xml b/bundles/pom.xml index f97ad738a32..07b13fcb284 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -160,8 +160,9 @@ org.openhab.binding.globalcache org.openhab.binding.gpstracker org.openhab.binding.gree - org.openhab.binding.groupepsa org.openhab.binding.groheondus + org.openhab.binding.groupepsa + org.openhab.binding.growatt org.openhab.binding.guntamatic org.openhab.binding.haassohnpelletstove org.openhab.binding.harmonyhub