]> git.basschouten.com Git - openhab-addons.git/commitdiff
[windcentrale] Adapt binding to new API (#14770)
authorWouter Born <github@maindrain.net>
Wed, 12 Apr 2023 21:01:36 +0000 (23:01 +0200)
committerGitHub <noreply@github.com>
Wed, 12 Apr 2023 21:01:36 +0000 (23:01 +0200)
* [windcentrale] Adapt binding to new API

Reworks the binding so it can be used with the new API that also requires authentication.

Also adds the following:

* Account things to provide authentication details
* Implementation for getting and refreshing tokens using AWS Cognito
* Windmill discovery based on the participations in projects
* Properties with additional data for windmills like turbine type, build year, location coordinates
* Adds support for "Het Vliegend Hert" windmill
* Unit tests for JSON (de)serialization

Fixes #13625

Signed-off-by: Wouter Born <github@maindrain.net>
51 files changed:
CODEOWNERS
bundles/org.openhab.binding.windcentrale/README.md
bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/WindcentraleBindingConstants.java
bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/WindcentraleDiscoveryService.java [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/WindcentraleHandlerFactory.java
bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/api/AuthenticationHelper.java [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/api/RequestListener.java [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/api/TokenProvider.java [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/api/WindcentraleAPI.java [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/config/AccountConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/config/MillConfig.java [deleted file]
bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/config/WindmillConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/AuthenticationResultResponse.java [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/ChallengeResponse.java [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/CognitoError.java [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/CognitoGson.java [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/InitiateAuthRequest.java [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/KeyResponse.java [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/Project.java [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/RespondToAuthChallengeRequest.java [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/WindcentraleGson.java [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/Windmill.java [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/WindmillStatus.java [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/exception/FailedGettingDataException.java [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/exception/InvalidAccessTokenException.java [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/handler/WindcentraleAccountHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/handler/WindcentraleHandler.java [deleted file]
bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/handler/WindcentraleWindmillHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/main/resources/OH-INF/addon/addon.xml
bundles/org.openhab.binding.windcentrale/src/main/resources/OH-INF/i18n/windcentrale.properties
bundles/org.openhab.binding.windcentrale/src/main/resources/OH-INF/i18n/windcentrale_nl.properties
bundles/org.openhab.binding.windcentrale/src/main/resources/OH-INF/thing/account.xml [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/main/resources/OH-INF/thing/millThing.xml [deleted file]
bundles/org.openhab.binding.windcentrale/src/main/resources/OH-INF/thing/windmill.xml [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/test/java/org/openhab/binding/windcentrale/internal/dto/CognitoGsonTest.java [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/test/java/org/openhab/binding/windcentrale/internal/dto/DataUtil.java [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/test/java/org/openhab/binding/windcentrale/internal/dto/WindcentraleGsonTest.java [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/test/java/org/openhab/binding/windcentrale/internal/dto/WindmillTest.java [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/authentication-result-response-challenge.json [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/authentication-result-response-refresh.json [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/challenge-response-srp.json [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/cognito-error-response-invalid-parameter.json [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/cognito-error-response-not-authorized.json [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/initiate-auth-request-refresh.json [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/initiate-auth-request-srp.json [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/key-response.json [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/live-data-response-empty.json [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/live-data-response-multiple.json [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/live-data-response-single.json [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/projects-response.json [new file with mode: 0644]
bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/respond-to-auth-challenge-request.json [new file with mode: 0644]

index b42817fa6e1bc13da10543c109c0df0250353719..05b0c15139d0366e22a1a6efa44cfa87c05de30c 100644 (file)
 /bundles/org.openhab.binding.webthing/ @grro
 /bundles/org.openhab.binding.wemo/ @hmerk @jlaur
 /bundles/org.openhab.binding.wifiled/ @rvt @xylo
-/bundles/org.openhab.binding.windcentrale/ @marcelrv
+/bundles/org.openhab.binding.windcentrale/ @marcelrv @wborn
 /bundles/org.openhab.binding.wlanthermo/ @CSchlipp
 /bundles/org.openhab.binding.wled/ @Skinah
 /bundles/org.openhab.binding.wolfsmartset/ @BoBiene
index eac87a5a9253116dcfe351b28c3d0e9125410d19..05d8bb6a74210bfffa4ebd85b0d06db89c8e26f3 100644 (file)
@@ -1,14 +1,19 @@
 # Windcentrale Binding
 
-This Binding is used to display the details of a Windcentrale windmill.
+This Binding is used to display the details of Windcentrale windmills.
 
 ## Supported Things
 
-This Binding supports Windcentrale mill devices.
+The binding supports the following Windcentrale Things:
+
+| Thing Type | Description                               |
+|------------|-------------------------------------------|
+| account    | An account for using the Windcentrale API |
+| windmill   | Windcentrale Windmill                     |
 
 ## Discovery
 
-There is no discovery available for this binding.
+After creating an account Thing the Binding can discover windmills based on the participations linked to the account.
 
 ## Binding Configuration
 
@@ -16,62 +21,72 @@ No binding configuration required.
 
 ## Thing Configuration
 
-| Configuration Parameter | Required | Default | Description                                         |
-|-------------------------|----------|---------|-----------------------------------------------------|
-| millId                  | X        | 131     | Identifies the windmill (see table below)           |
-| wd                      |          | 1       | Number of wind shares ("Winddelen")                 |
-| refreshInterval         |          | 30      | Refresh interval for refreshing the data in seconds |
-
-| millId | Windmill name     |
-|--------|-------------------|
-| 1      | De Grote Geert    |
-| 2      | De Jonge Held     |
-| 31     | Het Rode Hert     |
-| 41     | De Ranke Zwaan    |
-| 51     | De Witte Juffer   |
-| 111    | De Bonte Hen      |
-| 121    | De Trouwe Wachter |
-| 131    | De Blauwe Reiger  |
-| 141    | De Vier Winden    |
-| 201    | De Boerenzwaluw   |
+### Account
+
+| Configuration Parameter | Required |
+|-------------------------|----------|
+| username                | X        |
+| password                | X        |
+
+### Windmill
+
+| Configuration Parameter | Required | Default          | Description                                         |
+|-------------------------|----------|------------------|-----------------------------------------------------|
+| name                    | X        | De Blauwe Reiger | Identifies the windmill (see names list below)      |
+| shares                  |          | 1                | Number of wind shares ("Winddelen")                 |
+| refreshInterval         |          | 30               | Refresh interval for refreshing the data in seconds |
+
+The following windmill names are supported:
+
+- De Blauwe Reiger
+- De Boerenzwaluw
+- De Bonte Hen
+- De Grote Geert
+- De Jonge Held
+- De Ranke Zwaan
+- De Trouwe Wachter
+- De Vier Winden
+- De Witte Juffer
+- Het Rode Hert
+- Het Vliegend Hert
 
 ## Channels
 
-| Channel Type ID | Item Type            | Description                         |
-|-----------------|----------------------|-------------------------------------|
-| kwh             | Number:Energy        | Current energy                      |
-| kwhForecast     | Number:Energy        | Energy forecast                     |
-| powerAbsTot     | Number:Power         | Total power                         |
-| powerAbsWd      | Number:Power         | Power provided for your wind shares |
-| powerRel        | Number:Dimensionless | Relative power                      |
-| runPercentage   | Number:Dimensionless | Run percentage this year            |
-| runTime         | Number:Time          | Run time this year                  |
-| timestamp       | DateTime             | Timestamp of the last update        |
-| windDirection   | String               | Current wind direction              |
-| windSpeed       | Number               | Measured current wind speed (Bft)   |
+| Channel ID     | Item Type            | Description                         |
+|----------------|----------------------|-------------------------------------|
+| energy-total   | Number:Energy        | Total energy this year              |
+| power-relative | Number:Dimensionless | Relative power                      |
+| power-shares   | Number:Power         | Power provided for your wind shares |
+| power-total    | Number:Power         | Total power                         |
+| run-percentage | Number:Dimensionless | Run percentage this year            |
+| run-time       | Number:Time          | Run time this year                  |
+| timestamp      | DateTime             | Timestamp of the last update        |
+| wind-direction | String               | Current wind direction              |
+| wind-speed     | Number               | Measured current wind speed (Bft)   |
 
 ## Example
 
 ### demo.things
 
 ```java
-Thing windcentrale:mill:geert  [ millId=1 ]
-Thing windcentrale:mill:reiger [ millId=131, wd=3, refreshInterval=60 ]
+Bridge windcentrale:account:demo-account [ username="johndoe@acme.com", password="Mf!BU45LTF6X2Cf36zxt" ] {
+    Thing windmill    de-grote-geert      [ name="De Grote Geert" ]
+    Thing windmill    de-blauwe-reiger    [ name="De Blauwe Reiger", shares=3, refreshInterval=60 ]
+}
 ```
 
 ### demo.items
 
 ```java
-Group                 gReiger                 "Windcentrale Reiger"              <wind>
-
-Number                ReigerWindSpeed         "Wind speed [%d Bft]"              <wind>  (gReiger) { channel="windcentrale:mill:reiger:windSpeed" }
-String                ReigerWindDirection     "Wind direction [%s]"              <wind>  (gReiger) { channel="windcentrale:mill:reiger:windDirection" }
-Number:Power          ReigerPowerAbsTot       "Total mill power [%.1f %unit%]"   <wind>  (gReiger) { channel="windcentrale:mill:reiger:powerAbsTot" }
-Number:Power          ReigerPowerAbsWd        "Wind shares power [%.1f %unit%]"  <wind>  (gReiger) { channel="windcentrale:mill:reiger:powerAbsWd" }
-Number:Dimensionless  ReigerPowerRel          "Relative power [%.1f %unit%]"     <wind>  (gReiger) { channel="windcentrale:mill:reiger:powerRel" }
-Number:Energy         ReigerKwh               "Current energy [%.0f %unit%]"     <wind>  (gReiger) { channel="windcentrale:mill:reiger:kwh" }
-Number:Energy         ReigerKwhForecast       "Energy forecast [%.0f %unit%]"    <wind>  (gReiger) { channel="windcentrale:mill:reiger:kwhForecast" }
-Number:Dimensionless  ReigerRunPercentage     "Run percentage [%.1f %unit%]"     <wind>  (gReiger) { channel="windcentrale:mill:reiger:runPercentage" }
-Number:Time           ReigerRunTime           "Run time [%.0f %unit%]"           <wind>  (gReiger) { channel="windcentrale:mill:reiger:runTime" }
-DateTime              ReigerTimestamp         "Update timestamp [%1$ta %1$tR]"   <wind>  (gReiger) { channel="windcentrale:mill:reiger:timestamp" }
+Group                 gReiger                "Windcentrale Reiger"
+
+Number                ReigerWindSpeed        "Wind speed [%d Bft]"                  (gReiger) { channel="windcentrale:windmill:demo-account:de-blauwe-reiger:wind-speed" }
+String                ReigerWindDirection    "Wind direction [%s]"                  (gReiger) { channel="windcentrale:windmill:demo-account:de-blauwe-reiger:wind-direction" }
+Number:Power          ReigerPowerTotal       "Total windmill power [%.1f %unit%]"   (gReiger) { channel="windcentrale:windmill:demo-account:de-blauwe-reiger:power-total" }
+Number:Power          ReigerPowerShares      "Wind shares power [%.1f %unit%]"      (gReiger) { channel="windcentrale:windmill:demo-account:de-blauwe-reiger:power-shares" }
+Number:Dimensionless  ReigerPowerRelative    "Relative power [%.1f %unit%]"         (gReiger) { channel="windcentrale:windmill:demo-account:de-blauwe-reiger:power-relative" }
+Number:Energy         ReigerEnergyTotal      "Total windmill energy [%.0f %unit%]"  (gReiger) { channel="windcentrale:windmill:demo-account:de-blauwe-reiger:energy-total" }
+Number:Dimensionless  ReigerRunPercentage    "Run percentage [%.1f %unit%]"         (gReiger) { channel="windcentrale:windmill:demo-account:de-blauwe-reiger:run-percentage" }
+Number:Time           ReigerRunTime          "Run time [%.0f %unit%]"               (gReiger) { channel="windcentrale:windmill:demo-account:de-blauwe-reiger:run-time" }
+DateTime              ReigerTimestamp        "Update timestamp [%1$ta %1$tR]"       (gReiger) { channel="windcentrale:windmill:demo-account:de-blauwe-reiger:timestamp" }
 ```
index 1d7c51b0324ce17c9e0610698b0bf9ef35abe502..ac5f6594a39489a5c9c5c31943613aab190893b9 100644 (file)
@@ -12,7 +12,6 @@
  */
 package org.openhab.binding.windcentrale.internal;
 
-import java.util.Collections;
 import java.util.Set;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
@@ -23,6 +22,7 @@ import org.openhab.core.thing.ThingTypeUID;
  * used across the whole binding.
  *
  * @author Marcel Verpaalen - Initial contribution
+ * @author Wouter Born - Add support for new API with authentication
  */
 @NonNullByDefault
 public final class WindcentraleBindingConstants {
@@ -30,23 +30,31 @@ public final class WindcentraleBindingConstants {
     public static final String BINDING_ID = "windcentrale";
 
     // List of all Thing Type UIDs
-    public static final ThingTypeUID THING_TYPE_MILL = new ThingTypeUID(BINDING_ID, "mill");
+    public static final ThingTypeUID THING_TYPE_ACCOUNT = new ThingTypeUID(BINDING_ID, "account");
+    public static final ThingTypeUID THING_TYPE_WINDMILL = new ThingTypeUID(BINDING_ID, "windmill");
 
-    public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Collections.singleton(THING_TYPE_MILL);
+    public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_ACCOUNT, THING_TYPE_WINDMILL);
 
     // List of all Channel IDs
-    public static final String CHANNEL_WIND_SPEED = "windSpeed";
-    public static final String CHANNEL_WIND_DIRECTION = "windDirection";
-    public static final String CHANNEL_POWER_TOTAL = "powerAbsTot";
-    public static final String CHANNEL_POWER_PER_WD = "powerAbsWd";
-    public static final String CHANNEL_POWER_RELATIVE = "powerRel";
-    public static final String CHANNEL_ENERGY = "kwh";
-    public static final String CHANNEL_ENERGY_FC = "kwhForecast";
-    public static final String CHANNEL_RUNTIME = "runTime";
-    public static final String CHANNEL_RUNTIME_PER = "runPercentage";
-    public static final String CHANNEL_LAST_UPDATE = "timestamp";
-
-    public static final String PROPERTY_MILL_ID = "millId";
-    public static final String PROPERTY_QTY_WINDDELEN = "wd";
+    public static final String CHANNEL_ENERGY_TOTAL = "energy-total";
+    public static final String CHANNEL_POWER_RELATIVE = "power-relative";
+    public static final String CHANNEL_POWER_SHARES = "power-shares";
+    public static final String CHANNEL_POWER_TOTAL = "power-total";
+    public static final String CHANNEL_RUN_PERCENTAGE = "run-percentage";
+    public static final String CHANNEL_RUN_TIME = "run-time";
+    public static final String CHANNEL_TIMESTAMP = "timestamp";
+    public static final String CHANNEL_WIND_DIRECTION = "wind-direction";
+    public static final String CHANNEL_WIND_SPEED = "wind-speed";
+
+    public static final String PROPERTY_NAME = "name";
+    public static final String PROPERTY_SHARES = "shares";
     public static final String PROPERTY_REFRESH_INTERVAL = "refreshInterval";
+
+    public static final String PROPERTY_BUILD_YEAR = "buildYear";
+    public static final String PROPERTY_COORDINATES = "coordinates";
+    public static final String PROPERTY_DETAILS_URL = "detailsUrl";
+    public static final String PROPERTY_MUNICIPALITY = "municipality";
+    public static final String PROPERTY_PROJECT_CODE = "projectCode";
+    public static final String PROPERTY_PROVINCE = "province";
+    public static final String PROPERTY_TOTAL_SHARES = "totalShares";
 }
diff --git a/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/WindcentraleDiscoveryService.java b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/WindcentraleDiscoveryService.java
new file mode 100644 (file)
index 0000000..0c78e75
--- /dev/null
@@ -0,0 +1,153 @@
+/**
+ * Copyright (c) 2010-2023 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.windcentrale.internal;
+
+import static org.openhab.binding.windcentrale.internal.WindcentraleBindingConstants.*;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.Future;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.windcentrale.internal.api.WindcentraleAPI;
+import org.openhab.binding.windcentrale.internal.dto.Project;
+import org.openhab.binding.windcentrale.internal.dto.Windmill;
+import org.openhab.binding.windcentrale.internal.exception.FailedGettingDataException;
+import org.openhab.binding.windcentrale.internal.exception.InvalidAccessTokenException;
+import org.openhab.binding.windcentrale.internal.handler.WindcentraleAccountHandler;
+import org.openhab.binding.windcentrale.internal.handler.WindcentraleWindmillHandler;
+import org.openhab.core.config.discovery.AbstractDiscoveryService;
+import org.openhab.core.config.discovery.DiscoveryResultBuilder;
+import org.openhab.core.thing.ThingUID;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerService;
+import org.osgi.service.component.ComponentContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link WindcentraleDiscoveryService} discovers windmills using the participations in projects provided by the
+ * Windcentrale API.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class WindcentraleDiscoveryService extends AbstractDiscoveryService implements ThingHandlerService {
+
+    private final Logger logger = LoggerFactory.getLogger(WindcentraleDiscoveryService.class);
+    private @NonNullByDefault({}) WindcentraleAccountHandler accountHandler;
+    private @Nullable Future<?> discoveryJob;
+
+    public WindcentraleDiscoveryService() {
+        super(Set.of(THING_TYPE_WINDMILL), 10, false);
+    }
+
+    protected void activate(ComponentContext context) {
+    }
+
+    @Override
+    public void deactivate() {
+        cancelDiscoveryJob();
+        super.deactivate();
+    }
+
+    @Override
+    public @Nullable ThingHandler getThingHandler() {
+        return accountHandler;
+    }
+
+    @Override
+    public void setThingHandler(ThingHandler handler) {
+        if (handler instanceof WindcentraleAccountHandler accountHandler) {
+            this.accountHandler = accountHandler;
+        }
+    }
+
+    @Override
+    protected void startScan() {
+        cancelDiscoveryJob();
+        discoveryJob = scheduler.submit(this::discoverWindmills);
+    }
+
+    @Override
+    protected synchronized void stopScan() {
+        cancelDiscoveryJob();
+        super.stopScan();
+    }
+
+    private void cancelDiscoveryJob() {
+        Future<?> localDiscoveryJob = discoveryJob;
+        if (localDiscoveryJob != null) {
+            localDiscoveryJob.cancel(true);
+        }
+    }
+
+    private void discoverWindmills() {
+        ThingUID bridgeUID = accountHandler.getThing().getUID();
+        WindcentraleAPI api = accountHandler.getAPI();
+
+        if (api == null) {
+            logger.debug("Cannot discover windmills because API is null for {}", bridgeUID);
+            return;
+        }
+
+        logger.debug("Starting discovery scan for {}", bridgeUID);
+        try {
+            calculateWindmillShares(api.getProjects()).entrySet()
+                    .forEach(windmillShares -> addWindmillDiscoveryResult(bridgeUID, windmillShares.getKey(),
+                            windmillShares.getValue()));
+        } catch (FailedGettingDataException | InvalidAccessTokenException e) {
+            logger.debug("Exception during discovery scan for {}", bridgeUID, e);
+        }
+        logger.debug("Finished discovery scan for {}", bridgeUID);
+    }
+
+    private Map<Windmill, Integer> calculateWindmillShares(List<Project> projects) {
+        Map<Windmill, Integer> windmillShares = new HashMap<>();
+
+        for (Project project : projects) {
+            Windmill windmill = Windmill.fromProjectCode(project.projectCode);
+            if (windmill != null) {
+                int shares = Objects.requireNonNullElse(windmillShares.get(windmill), 0);
+                shares += project.participations.stream()
+                        .collect(Collectors.summingInt(participation -> participation.share));
+                windmillShares.put(windmill, shares);
+            } else {
+                logger.debug("Unsupported project code: {}", project.projectCode);
+            }
+        }
+
+        return windmillShares;
+    }
+
+    private void addWindmillDiscoveryResult(ThingUID bridgeUID, Windmill windmill, int shares) {
+        String deviceId = windmill.getName().toLowerCase().replaceAll(" ", "-");
+        ThingUID thingUID = new ThingUID(THING_TYPE_WINDMILL, bridgeUID, deviceId);
+
+        thingDiscovered(DiscoveryResultBuilder.create(thingUID) //
+                .withThingType(THING_TYPE_WINDMILL) //
+                .withLabel(windmill.getName()) //
+                .withBridge(bridgeUID) //
+                .withProperty(PROPERTY_NAME, windmill.getName()) //
+                .withProperty(PROPERTY_SHARES, shares) //
+                .withProperties(new HashMap<>(WindcentraleWindmillHandler.getWindmillProperties(windmill))) //
+                .withRepresentationProperty(PROPERTY_PROJECT_CODE) //
+                .build() //
+        );
+    }
+}
index 5a3bd6ea5a6e284dfe991f1a1b48b9b2008e63b7..3f89f78e34a2a1c0c068f297282c7c1109980d54 100644 (file)
@@ -16,24 +16,37 @@ import static org.openhab.binding.windcentrale.internal.WindcentraleBindingConst
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.eclipse.jdt.annotation.Nullable;
-import org.openhab.binding.windcentrale.internal.handler.WindcentraleHandler;
+import org.openhab.binding.windcentrale.internal.handler.WindcentraleAccountHandler;
+import org.openhab.binding.windcentrale.internal.handler.WindcentraleWindmillHandler;
+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.binding.BaseThingHandlerFactory;
 import org.openhab.core.thing.binding.ThingHandler;
 import org.openhab.core.thing.binding.ThingHandlerFactory;
+import org.osgi.service.component.annotations.Activate;
 import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
 
 /**
  * The {@link WindcentraleHandlerFactory} is responsible for creating things and thing
  * handlers.
  *
  * @author Marcel Verpaalen - Initial contribution
+ * @author Wouter Born - Add support for new API with authentication
  */
 @Component(service = ThingHandlerFactory.class, configurationPid = "binding.windcentrale")
 @NonNullByDefault
 public class WindcentraleHandlerFactory extends BaseThingHandlerFactory {
 
+    private final HttpClientFactory httpClientFactory;
+
+    @Activate
+    public WindcentraleHandlerFactory(final @Reference HttpClientFactory httpClientFactory) {
+        this.httpClientFactory = httpClientFactory;
+    }
+
     @Override
     public boolean supportsThingType(ThingTypeUID thingTypeUID) {
         return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
@@ -43,8 +56,10 @@ public class WindcentraleHandlerFactory extends BaseThingHandlerFactory {
     protected @Nullable ThingHandler createHandler(Thing thing) {
         ThingTypeUID thingTypeUID = thing.getThingTypeUID();
 
-        if (thingTypeUID.equals(THING_TYPE_MILL)) {
-            return new WindcentraleHandler(thing);
+        if (thingTypeUID.equals(THING_TYPE_ACCOUNT)) {
+            return new WindcentraleAccountHandler((Bridge) thing, httpClientFactory);
+        } else if (thingTypeUID.equals(THING_TYPE_WINDMILL)) {
+            return new WindcentraleWindmillHandler(thing);
         }
 
         return null;
diff --git a/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/api/AuthenticationHelper.java b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/api/AuthenticationHelper.java
new file mode 100644 (file)
index 0000000..e78cd2b
--- /dev/null
@@ -0,0 +1,395 @@
+/**
+ * Copyright (c) 2010-2023 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.windcentrale.internal.api;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jetty.http.HttpMethod.POST;
+import static org.openhab.binding.windcentrale.internal.dto.CognitoGson.GSON;
+
+import java.math.BigInteger;
+import java.security.InvalidKeyException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.Base64;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import javax.crypto.Mac;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+
+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.util.StringContentProvider;
+import org.openhab.binding.windcentrale.internal.dto.AuthenticationResultResponse;
+import org.openhab.binding.windcentrale.internal.dto.ChallengeResponse;
+import org.openhab.binding.windcentrale.internal.dto.CognitoError;
+import org.openhab.binding.windcentrale.internal.dto.InitiateAuthRequest;
+import org.openhab.binding.windcentrale.internal.dto.RespondToAuthChallengeRequest;
+import org.openhab.binding.windcentrale.internal.exception.InvalidAccessTokenException;
+import org.openhab.core.io.net.http.HttpClientFactory;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Helps with authenticating users to Amazon Cognito to get a JWT access token which can be used for retrieving
+ * information using the REST APIs.
+ *
+ * @see https://en.wikipedia.org/wiki/Secure_Remote_Password_protocol
+ * @see https://stackoverflow.com/questions/67528443/cognito-srp-using-aws-java-sdk-v2-x
+ * @see https://github.com/aws-samples/aws-cognito-java-desktop-app/blob/master/src/main/java/com/amazonaws/sample/cognitoui/AuthenticationHelper.java
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class AuthenticationHelper {
+
+    private final Logger logger = LoggerFactory.getLogger(AuthenticationHelper.class);
+
+    private static final String SRP_N_HEX = "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" //
+            + "29024E088A67CC74020BBEA63B139B22514A08798E3404DD" //
+            + "EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245" //
+            + "E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" //
+            + "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D" //
+            + "C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F" //
+            + "83655D23DCA3AD961C62F356208552BB9ED529077096966D" //
+            + "670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B" //
+            + "E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9" //
+            + "DE2BCBF6955817183995497CEA956AE515D2261898FA0510" //
+            + "15728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64" //
+            + "ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7" //
+            + "ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6B" //
+            + "F12FFA06D98A0864D87602733EC86A64521F2B18177B200C" //
+            + "BBE117577A615D6C770988C0BAD946E208E24FA074E5AB31" //
+            + "43DB5BFCE0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF";
+
+    private static final BigInteger SRP_A;
+    private static final BigInteger SRP_A2;
+    private static final BigInteger SRP_G = BigInteger.valueOf(2);
+    private static final BigInteger SRP_K;
+    private static final BigInteger SRP_N = new BigInteger(SRP_N_HEX, 16);
+
+    private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter
+            .ofPattern("EEE MMM d HH:mm:ss z yyyy", Locale.US).withZone(ZoneId.of("UTC"));
+    private static final int DERIVED_KEY_SIZE = 16;
+    private static final int EPHEMERAL_KEY_LENGTH = 1024;
+    private static final String DERIVED_KEY_INFO = "Caldera Derived Key";
+    private static final Duration REQUEST_TIMEOUT = Duration.ofMinutes(1);
+
+    private static final String COGNITO_URL_FORMAT = "https://cognito-idp.%s.amazonaws.com/";
+    private static final String INITIATE_AUTH_TARGET = "AWSCognitoIdentityProviderService.InitiateAuth";
+    private static final String RESPOND_TO_AUTH_TARGET = "AWSCognitoIdentityProviderService.RespondToAuthChallenge";
+
+    /**
+     * Internal class for doing the HKDF calculations.
+     */
+    private static final class Hkdf {
+        private static final int MAX_KEY_SIZE = 255;
+        private final String algorithm;
+        private @Nullable SecretKey prk;
+
+        /**
+         * @param algorithm The type of HMAC algorithm to be used
+         */
+        private Hkdf(String algorithm) {
+            if (!algorithm.startsWith("Hmac")) {
+                throw new IllegalArgumentException(
+                        "Invalid algorithm " + algorithm + ". HKDF may only be used with HMAC algorithms.");
+            }
+            this.algorithm = algorithm;
+        }
+
+        /**
+         * @param ikm the input key material
+         * @param salt random bytes for salt
+         */
+        private void init(byte[] ikm, byte[] salt) {
+            try {
+                Mac mac = Mac.getInstance(algorithm);
+                byte[] realSalt = salt.length == 0 ? new byte[mac.getMacLength()] : salt.clone();
+                mac.init(new SecretKeySpec(realSalt, algorithm));
+                SecretKeySpec key = new SecretKeySpec(mac.doFinal(ikm), algorithm);
+                unsafeInitWithoutKeyExtraction(key);
+            } catch (InvalidKeyException | NoSuchAlgorithmException e) {
+                throw new IllegalStateException("Failed to initialize HKDF", e);
+            }
+        }
+
+        /**
+         * @param rawKey current secret key
+         */
+        private void unsafeInitWithoutKeyExtraction(SecretKey rawKey) {
+            if (!rawKey.getAlgorithm().equals(algorithm)) {
+                throw new IllegalArgumentException(
+                        "Algorithm for the provided key must match the algorithm for this HKDF. Expected " + algorithm
+                                + " but found " + rawKey.getAlgorithm());
+            } else {
+                prk = rawKey;
+            }
+        }
+
+        private byte[] deriveKey(String info, int length) {
+            if (prk == null) {
+                throw new IllegalStateException("HKDF has not been initialized");
+            }
+
+            if (length < 0) {
+                throw new IllegalArgumentException("Length must be a non-negative value");
+            }
+
+            Mac mac = createMac();
+            if (length > MAX_KEY_SIZE * mac.getMacLength()) {
+                throw new IllegalArgumentException(
+                        "Requested keys may not be longer than 255 times the underlying HMAC length");
+            }
+
+            byte[] result = new byte[length];
+            byte[] bytes = info.getBytes(UTF_8);
+            byte[] t = {};
+            int loc = 0;
+
+            for (byte i = 1; loc < length; ++i) {
+                mac.update(t);
+                mac.update(bytes);
+                mac.update(i);
+                t = mac.doFinal();
+
+                for (int x = 0; x < t.length && loc < length; ++loc) {
+                    result[loc] = t[x];
+                    ++x;
+                }
+            }
+
+            return result;
+        }
+
+        /**
+         * @return the generated message authentication code
+         */
+        private Mac createMac() {
+            try {
+                Mac mac = Mac.getInstance(algorithm);
+                mac.init(prk);
+                return mac;
+            } catch (InvalidKeyException | NoSuchAlgorithmException e) {
+                throw new IllegalStateException("Could not create MAC implementing algorithm: " + algorithm, e);
+            }
+        }
+    }
+
+    static {
+        // Initialize the SRP variables
+        try {
+            SecureRandom sr = SecureRandom.getInstance("SHA1PRNG");
+            MessageDigest md = MessageDigest.getInstance("SHA-256");
+            md.update(SRP_N.toByteArray());
+
+            byte[] digest = md.digest(SRP_G.toByteArray());
+            SRP_K = new BigInteger(1, digest);
+
+            BigInteger srpA;
+            BigInteger srpA2;
+            do {
+                srpA2 = new BigInteger(EPHEMERAL_KEY_LENGTH, sr).mod(SRP_N);
+                srpA = SRP_G.modPow(srpA2, SRP_N);
+            } while (srpA.mod(SRP_N).equals(BigInteger.ZERO));
+
+            SRP_A = srpA;
+            SRP_A2 = srpA2;
+        } catch (NoSuchAlgorithmException e) {
+            throw new IllegalStateException("SRP variables cannot be initialized due to missing algorithm", e);
+        }
+    }
+
+    private final HttpClient httpClient;
+    private final String userPoolId;
+    private final String clientId;
+    private final String region;
+
+    public AuthenticationHelper(HttpClientFactory httpClientFactory, String userPoolId, String clientId,
+            String region) {
+        this.httpClient = httpClientFactory.getCommonHttpClient();
+        this.userPoolId = userPoolId;
+        this.clientId = clientId;
+        this.region = region;
+    }
+
+    /**
+     * Method to orchestrate the SRP Authentication.
+     *
+     * @param username username for the SRP request
+     * @param password password for the SRP request
+     * @return JWT token if the request is successful
+     * @throws InvalidAccessTokenException when SRP authentication fails
+     */
+    public AuthenticationResultResponse performSrpAuthentication(String username, String password)
+            throws InvalidAccessTokenException {
+        InitiateAuthRequest initiateAuthRequest = InitiateAuthRequest.userSrpAuth(clientId, username,
+                SRP_A.toString(16));
+        try {
+            ChallengeResponse challengeResponse = postInitiateAuthSrp(initiateAuthRequest);
+            if ("PASSWORD_VERIFIER".equals(challengeResponse.challengeName)) {
+                RespondToAuthChallengeRequest challengeRequest = createRespondToAuthChallengeRequest(challengeResponse,
+                        password);
+                return postRespondToAuthChallenge(challengeRequest);
+            } else {
+                throw new InvalidAccessTokenException(
+                        "Unsupported authentication challenge: " + challengeResponse.challengeName);
+            }
+        } catch (IllegalStateException | InvalidKeyException | NoSuchAlgorithmException e) {
+            throw new InvalidAccessTokenException("SRP Authentication failed", e);
+        }
+    }
+
+    public AuthenticationResultResponse performTokenRefresh(String refreshToken) throws InvalidAccessTokenException {
+        InitiateAuthRequest initiateAuthRequest = InitiateAuthRequest.refreshTokenAuth(clientId, refreshToken);
+        try {
+            return postInitiateAuthRefresh(initiateAuthRequest);
+        } catch (IllegalStateException e) {
+            throw new InvalidAccessTokenException("Token refresh failed", e);
+        }
+    }
+
+    /**
+     * Creates a response request to the SRP authentication challenge from the user pool.
+     *
+     * @param challengeResponse authentication challenge returned from the Cognito user pool
+     * @param password password to be used to respond to the authentication challenge
+     * @return request created for the previous authentication challenge
+     */
+    private RespondToAuthChallengeRequest createRespondToAuthChallengeRequest(ChallengeResponse challengeResponse,
+            String password) throws InvalidKeyException, NoSuchAlgorithmException {
+        String salt = challengeResponse.getSalt();
+        String secretBlock = challengeResponse.getSecretBlock();
+        String userIdForSrp = challengeResponse.getUserIdForSrp();
+        String usernameInternal = challengeResponse.getUsername();
+
+        if (secretBlock.isEmpty() || userIdForSrp.isEmpty() || usernameInternal.isEmpty()) {
+            throw new IllegalArgumentException("Required authentication response challenge parameters are null");
+        }
+
+        BigInteger srpB = new BigInteger(challengeResponse.getSrpB(), 16);
+        if (srpB.mod(SRP_N).equals(BigInteger.ZERO)) {
+            throw new IllegalStateException("SRP error, B cannot be zero");
+        }
+
+        String timestamp = DATE_TIME_FORMATTER.format(Instant.now());
+
+        byte[] key = getPasswordAuthenticationKey(userIdForSrp, password, srpB, new BigInteger(salt, 16));
+
+        Mac mac = Mac.getInstance("HmacSHA256");
+        mac.init(new SecretKeySpec(key, "HmacSHA256"));
+        mac.update(userPoolId.split("_", 2)[1].getBytes(UTF_8));
+        mac.update(userIdForSrp.getBytes(UTF_8));
+        mac.update(Base64.getDecoder().decode(secretBlock));
+        byte[] hmac = mac.doFinal(timestamp.getBytes(UTF_8));
+
+        String signature = new String(Base64.getEncoder().encode(hmac), UTF_8);
+
+        return new RespondToAuthChallengeRequest(clientId, usernameInternal, secretBlock, signature, timestamp);
+    }
+
+    private byte[] getPasswordAuthenticationKey(String userId, String userPassword, BigInteger srpB, BigInteger salt) {
+        try {
+            // Authenticate the password
+            // srpU = H(SRP_A, srpB)
+            MessageDigest md = MessageDigest.getInstance("SHA-256");
+            md.update(SRP_A.toByteArray());
+
+            BigInteger srpU = new BigInteger(1, md.digest(srpB.toByteArray()));
+            if (srpU.equals(BigInteger.ZERO)) {
+                throw new IllegalStateException("Hash of A and B cannot be zero");
+            }
+
+            // srpX = H(salt | H(poolName | userId | ":" | password))
+            md.reset();
+            md.update(userPoolId.split("_", 2)[1].getBytes(UTF_8));
+            md.update(userId.getBytes(UTF_8));
+            md.update(":".getBytes(UTF_8));
+
+            byte[] userIdHash = md.digest(userPassword.getBytes(UTF_8));
+
+            md.reset();
+            md.update(salt.toByteArray());
+
+            BigInteger srpX = new BigInteger(1, md.digest(userIdHash));
+            BigInteger srpS = (srpB.subtract(SRP_K.multiply(SRP_G.modPow(srpX, SRP_N)))
+                    .modPow(SRP_A2.add(srpU.multiply(srpX)), SRP_N)).mod(SRP_N);
+
+            Hkdf hkdf = new Hkdf("HmacSHA256");
+            hkdf.init(srpS.toByteArray(), srpU.toByteArray());
+            return hkdf.deriveKey(DERIVED_KEY_INFO, DERIVED_KEY_SIZE);
+        } catch (NoSuchAlgorithmException e) {
+            throw new IllegalStateException(e.getMessage(), e);
+        }
+    }
+
+    private ChallengeResponse postInitiateAuthSrp(InitiateAuthRequest request) throws InvalidAccessTokenException {
+        String responseContent = postJson(INITIATE_AUTH_TARGET, GSON.toJson(request));
+        return Objects.requireNonNull(GSON.fromJson(responseContent, ChallengeResponse.class));
+    }
+
+    private AuthenticationResultResponse postInitiateAuthRefresh(InitiateAuthRequest request)
+            throws InvalidAccessTokenException {
+        String responseContent = postJson(INITIATE_AUTH_TARGET, GSON.toJson(request));
+        return Objects.requireNonNull(GSON.fromJson(responseContent, AuthenticationResultResponse.class));
+    }
+
+    private AuthenticationResultResponse postRespondToAuthChallenge(RespondToAuthChallengeRequest request)
+            throws InvalidAccessTokenException {
+        String responseContent = postJson(RESPOND_TO_AUTH_TARGET, GSON.toJson(request));
+        return Objects.requireNonNull(GSON.fromJson(responseContent, AuthenticationResultResponse.class));
+    }
+
+    private String postJson(String target, String requestContent) throws InvalidAccessTokenException {
+        try {
+            String url = String.format(COGNITO_URL_FORMAT, region);
+            logger.debug("Posting JSON to: {}", url);
+            ContentResponse contentResponse = httpClient.newRequest(url) //
+                    .method(POST) //
+                    .header("x-amz-target", target) //
+                    .content(new StringContentProvider(requestContent), "application/x-amz-json-1.1") //
+                    .timeout(REQUEST_TIMEOUT.toNanos(), TimeUnit.NANOSECONDS).send();
+
+            String response = contentResponse.getContentAsString();
+            if (contentResponse.getStatus() >= 400) {
+                logger.debug("Cognito API error: {}", response);
+
+                CognitoError error = GSON.fromJson(response, CognitoError.class);
+                String message;
+                if (error != null && !error.message.isBlank()) {
+                    message = String.format("Cognito API error: %s (%s)", error.message, error.type);
+                } else {
+                    message = String.format("Cognito API error: %s (HTTP %s)", contentResponse.getReason(),
+                            contentResponse.getStatus());
+                }
+                throw new InvalidAccessTokenException(message);
+            } else {
+                logger.trace("Response: {}", response);
+            }
+            return response;
+        } catch (InterruptedException | TimeoutException | ExecutionException e) {
+            throw new InvalidAccessTokenException("Cognito API request failed: " + e.getMessage(), e);
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/api/RequestListener.java b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/api/RequestListener.java
new file mode 100644 (file)
index 0000000..6d5491e
--- /dev/null
@@ -0,0 +1,28 @@
+/**
+ * Copyright (c) 2010-2023 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.windcentrale.internal.api;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Interface for listeners that want to monitor if {@link WindcentraleAPI} requests error or succeed.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public interface RequestListener {
+
+    void onError(Exception exception);
+
+    void onSuccess();
+}
diff --git a/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/api/TokenProvider.java b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/api/TokenProvider.java
new file mode 100644 (file)
index 0000000..090f9ac
--- /dev/null
@@ -0,0 +1,156 @@
+/**
+ * Copyright (c) 2010-2023 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.windcentrale.internal.api;
+
+import static org.eclipse.jetty.http.HttpHeader.ACCEPT;
+import static org.eclipse.jetty.http.HttpMethod.GET;
+import static org.openhab.binding.windcentrale.internal.dto.WindcentraleGson.GSON;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Objects;
+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.api.ContentResponse;
+import org.openhab.binding.windcentrale.internal.dto.AuthenticationResultResponse;
+import org.openhab.binding.windcentrale.internal.dto.KeyResponse;
+import org.openhab.binding.windcentrale.internal.exception.InvalidAccessTokenException;
+import org.openhab.core.io.net.http.HttpClientFactory;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Provides the JWT tokens used with the Windcentrale API by using a {@link AuthenticationHelper}.
+ * It also resolves the Windcentrale specific Cognito configuration required by the {@link AuthenticationHelper}.
+ *
+ * A token is obtained by calling {@link #getIdToken()}.
+ * The token is cached and returned in subsequent calls to {@link #getIdToken()} until it expires.
+ * When tokens expire they are refreshed using the refresh token when available.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class TokenProvider {
+
+    private final Logger logger = LoggerFactory.getLogger(TokenProvider.class);
+
+    private static final String DEFAULT_USER_POOL_ID = "eu-west-1_U7eYBPrBd";
+    private static final String DEFAULT_CLIENT_ID = "715j3r0trk7o8dqg3md57il7q0";
+    private static final String DEFAULT_REGION = "eu-west-1";
+
+    private static final String APPLICATION_JSON = "application/json";
+    private static final Duration REQUEST_TIMEOUT = Duration.ofMinutes(1);
+    private static final String KEY_URL = WindcentraleAPI.URL_PREFIX + "/labels/key?domain=mijn.windcentrale.nl";
+
+    private final HttpClientFactory httpClientFactory;
+
+    private final String username;
+    private final String password;
+
+    private @Nullable AuthenticationHelper authenticationHelper;
+
+    private String idToken = "";
+    private String refreshToken = "";
+    private Instant validityEnd = Instant.MIN;
+
+    public TokenProvider(HttpClientFactory httpClientFactory, String username, String password) {
+        this.httpClientFactory = httpClientFactory;
+        this.username = username;
+        this.password = password;
+    }
+
+    private AuthenticationHelper createHelper() {
+        String userPoolId = DEFAULT_USER_POOL_ID;
+        String clientId = DEFAULT_CLIENT_ID;
+        String region = DEFAULT_REGION;
+
+        try {
+            logger.debug("Getting JSON from: {}", KEY_URL);
+            ContentResponse contentResponse = httpClientFactory.getCommonHttpClient().newRequest(KEY_URL) //
+                    .method(GET) //
+                    .header(ACCEPT, APPLICATION_JSON) //
+                    .timeout(REQUEST_TIMEOUT.toNanos(), TimeUnit.NANOSECONDS) //
+                    .send();
+
+            String response = contentResponse.getContentAsString();
+            if (contentResponse.getStatus() >= 400) {
+                logger.debug("Could not get Cognito configuration values, using default values. Error (HTTP {}): {}",
+                        contentResponse.getStatus(), contentResponse.getReason());
+            } else {
+                logger.trace("Response: {}", response);
+                KeyResponse keyResponse = Objects.requireNonNullElse(GSON.fromJson(response, KeyResponse.class),
+                        new KeyResponse());
+                if (!keyResponse.userPoolId.isEmpty() && !keyResponse.clientId.isEmpty()
+                        && keyResponse.region.isEmpty()) {
+                    userPoolId = keyResponse.userPoolId;
+                    clientId = keyResponse.clientId;
+                    region = keyResponse.region;
+                }
+            }
+        } catch (ExecutionException | InterruptedException | TimeoutException e) {
+            logger.debug("Could not get Cognito configuration values, using default values", e);
+        }
+
+        logger.debug("Creating new AuthenticationHelper (userPoolId={}, clientId={}, region={})", userPoolId, clientId,
+                region);
+        return new AuthenticationHelper(httpClientFactory, userPoolId, clientId, region);
+    }
+
+    private AuthenticationHelper getOrCreateHelper() {
+        AuthenticationHelper helper = authenticationHelper;
+        if (helper == null) {
+            helper = createHelper();
+            this.authenticationHelper = helper;
+        }
+        return helper;
+    }
+
+    public String getIdToken() throws InvalidAccessTokenException {
+        boolean valid = Instant.now().plusSeconds(30).isBefore(validityEnd);
+        if (valid) {
+            logger.debug("Reusing existing valid token");
+            return idToken;
+        }
+
+        AuthenticationResultResponse result = null;
+        AuthenticationHelper helper = getOrCreateHelper();
+
+        if (!refreshToken.isBlank()) {
+            try {
+                logger.debug("Performing token refresh");
+                result = helper.performTokenRefresh(refreshToken);
+                logger.debug("Successfully performed token refresh");
+            } catch (InvalidAccessTokenException e) {
+                logger.debug("Token refresh failed", e);
+            }
+        }
+
+        if (result == null) {
+            // there is no refresh token or the refresh failed
+            logger.debug("Performing SRP authentication");
+            result = helper.performSrpAuthentication(username, password);
+            logger.debug("Successfully performed SRP authentication");
+
+            refreshToken = result.getRefreshToken();
+        }
+
+        idToken = result.getIdToken();
+        validityEnd = Instant.now().plusSeconds(result.getExpiresIn());
+        logger.debug("Token is valid until {}", validityEnd);
+        return idToken;
+    }
+}
diff --git a/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/api/WindcentraleAPI.java b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/api/WindcentraleAPI.java
new file mode 100644 (file)
index 0000000..400983e
--- /dev/null
@@ -0,0 +1,148 @@
+/**
+ * Copyright (c) 2010-2023 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.windcentrale.internal.api;
+
+import static org.eclipse.jetty.http.HttpHeader.*;
+import static org.eclipse.jetty.http.HttpMethod.GET;
+import static org.openhab.binding.windcentrale.internal.dto.WindcentraleGson.*;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.stream.Collectors;
+
+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.openhab.binding.windcentrale.internal.dto.Project;
+import org.openhab.binding.windcentrale.internal.dto.Windmill;
+import org.openhab.binding.windcentrale.internal.dto.WindmillStatus;
+import org.openhab.binding.windcentrale.internal.exception.FailedGettingDataException;
+import org.openhab.binding.windcentrale.internal.exception.InvalidAccessTokenException;
+import org.openhab.core.io.net.http.HttpClientFactory;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link WindcentraleAPI} implements the Windcentrale REST API which allows for querying project participations and
+ * the current windmill status.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class WindcentraleAPI {
+
+    public static final String URL_PREFIX = "https://mijn.windcentrale.nl/api/v0";
+    private static final String LIVE_DATA_URL = URL_PREFIX + "/livedata";
+    private static final String PROJECTS_URL = URL_PREFIX + "/sustainable/projects";
+
+    private static final String APPLICATION_JSON = "application/json";
+    private static final String BEARER = "Bearer ";
+    private static final Duration REQUEST_TIMEOUT = Duration.ofMinutes(1);
+
+    private final Logger logger = LoggerFactory.getLogger(WindcentraleAPI.class);
+
+    private final HttpClient httpClient;
+    private final TokenProvider tokenProvider;
+
+    private final Set<RequestListener> requestListeners = ConcurrentHashMap.newKeySet();
+
+    public WindcentraleAPI(HttpClientFactory httpClientFactory, TokenProvider tokenProvider) {
+        this.httpClient = httpClientFactory.getCommonHttpClient();
+        this.tokenProvider = tokenProvider;
+    }
+
+    public void dispose() {
+        requestListeners.clear();
+    }
+
+    public void addRequestListener(RequestListener listener) {
+        requestListeners.add(listener);
+    }
+
+    public void removeRequestListener(RequestListener listener) {
+        requestListeners.remove(listener);
+    }
+
+    private String getAuthorizationHeader() throws InvalidAccessTokenException {
+        return BEARER + tokenProvider.getIdToken();
+    }
+
+    private String getJson(String url) throws FailedGettingDataException, InvalidAccessTokenException {
+        try {
+            logger.debug("Getting JSON from: {}", url);
+            ContentResponse contentResponse = httpClient.newRequest(url) //
+                    .method(GET) //
+                    .header(ACCEPT, APPLICATION_JSON) //
+                    .header(AUTHORIZATION, getAuthorizationHeader()) //
+                    .timeout(REQUEST_TIMEOUT.toNanos(), TimeUnit.NANOSECONDS) //
+                    .send();
+
+            if (contentResponse.getStatus() >= 400) {
+                FailedGettingDataException exception = new FailedGettingDataException(
+                        String.format("Windcentrale API error: %s (HTTP %s)", contentResponse.getReason(),
+                                contentResponse.getStatus()));
+                requestListeners.forEach(listener -> listener.onError(exception));
+                throw exception;
+            }
+            String response = contentResponse.getContentAsString();
+            logger.trace("Response: {}", response);
+            requestListeners.forEach(RequestListener::onSuccess);
+            return response;
+        } catch (ExecutionException | InterruptedException | TimeoutException e) {
+            FailedGettingDataException exception = new FailedGettingDataException(
+                    "Windcentrale API request failed: " + e.getMessage(), e);
+            requestListeners.forEach(listener -> listener.onError(exception));
+            throw exception;
+        } catch (InvalidAccessTokenException e) {
+            requestListeners.forEach(listener -> listener.onError(e));
+            throw e;
+        }
+    }
+
+    public Map<Windmill, WindmillStatus> getLiveData() throws FailedGettingDataException, InvalidAccessTokenException {
+        return getLiveData(Set.of());
+    }
+
+    public @Nullable WindmillStatus getLiveData(Windmill windmill)
+            throws FailedGettingDataException, InvalidAccessTokenException {
+        return getLiveData(Set.of(windmill)).get(windmill);
+    }
+
+    public Map<Windmill, WindmillStatus> getLiveData(Set<Windmill> windmills)
+            throws FailedGettingDataException, InvalidAccessTokenException {
+        logger.debug("Getting live data: {}", windmills);
+
+        String queryParams = "";
+        if (!windmills.isEmpty()) {
+            queryParams = "?projects="
+                    + windmills.stream().map(Windmill::getProjectCode).collect(Collectors.joining(","));
+        }
+
+        String json = getJson(LIVE_DATA_URL + queryParams);
+        return Objects.requireNonNullElse(GSON.fromJson(json, LIVE_DATA_RESPONSE_TYPE), Map.of());
+    }
+
+    public List<Project> getProjects() throws FailedGettingDataException, InvalidAccessTokenException {
+        logger.debug("Getting projects");
+        String json = getJson(PROJECTS_URL);
+        return Objects.requireNonNullElse(GSON.fromJson(json, PROJECTS_RESPONSE_TYPE), List.of());
+    }
+}
diff --git a/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/config/AccountConfiguration.java b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/config/AccountConfiguration.java
new file mode 100644 (file)
index 0000000..caf2c01
--- /dev/null
@@ -0,0 +1,29 @@
+/**
+ * Copyright (c) 2010-2023 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.windcentrale.internal.config;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The configuration of a Windcentrale account thing.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class AccountConfiguration {
+    public static final String USERNAME = "username";
+    public String username = "";
+
+    public static final String PASSWORD = "password";
+    public String password = "";
+}
diff --git a/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/config/MillConfig.java b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/config/MillConfig.java
deleted file mode 100644 (file)
index a1c608f..0000000
+++ /dev/null
@@ -1,39 +0,0 @@
-/**
- * Copyright (c) 2010-2023 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.windcentrale.internal.config;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-
-/**
- * The configuration of a Mill thing.
- *
- * @author Wouter Born - Initial contribution, add Mill configuration object
- */
-@NonNullByDefault
-public class MillConfig {
-
-    /**
-     * Windmill identifier
-     */
-    public int millId = 1;
-
-    /**
-     * Refresh interval for refreshing the data in seconds
-     */
-    public int refreshInterval = 30;
-
-    /**
-     * Number of wind shares ("Winddelen")
-     */
-    public int wd = 1;
-}
diff --git a/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/config/WindmillConfiguration.java b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/config/WindmillConfiguration.java
new file mode 100644 (file)
index 0000000..e2c64ae
--- /dev/null
@@ -0,0 +1,40 @@
+/**
+ * Copyright (c) 2010-2023 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.windcentrale.internal.config;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The configuration of a Windcentrale windmill thing.
+ *
+ * @author Wouter Born - Initial contribution, add Mill configuration object
+ * @author Wouter Born - Add support for new API with authentication
+ */
+@NonNullByDefault
+public class WindmillConfiguration {
+
+    /**
+     * Windmill name
+     */
+    public String name = "";
+
+    /**
+     * Refresh interval for refreshing the data in seconds
+     */
+    public int refreshInterval = 30;
+
+    /**
+     * Number of wind shares ("Winddelen")
+     */
+    public int shares = 1;
+}
diff --git a/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/AuthenticationResultResponse.java b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/AuthenticationResultResponse.java
new file mode 100644 (file)
index 0000000..98e7eb6
--- /dev/null
@@ -0,0 +1,58 @@
+/**
+ * Copyright (c) 2010-2023 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.windcentrale.internal.dto;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link AuthenticationResultResponse} is returned by Cognito after responding to an SRP challenge by a
+ * {@link RespondToAuthChallengeRequest} or when refreshing tokens using an {@link InitiateAuthRequest}.
+ *
+ * The refresh token is only provided as part of the SRP challenge response and will be empty when it is used to refresh
+ * tokens.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class AuthenticationResultResponse {
+
+    private static class AuthenticationResult {
+        public String accessToken = "";
+        public int expiresIn;
+        public String idToken = "";
+        public String refreshToken = "";
+        public String tokenType = "";
+    }
+
+    private AuthenticationResult authenticationResult = new AuthenticationResult();
+
+    public String getAccessToken() {
+        return authenticationResult.accessToken;
+    }
+
+    public int getExpiresIn() {
+        return authenticationResult.expiresIn;
+    }
+
+    public String getIdToken() {
+        return authenticationResult.idToken;
+    }
+
+    public String getRefreshToken() {
+        return authenticationResult.refreshToken;
+    }
+
+    public String getTokenType() {
+        return authenticationResult.tokenType;
+    }
+}
diff --git a/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/ChallengeResponse.java b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/ChallengeResponse.java
new file mode 100644 (file)
index 0000000..c904e9a
--- /dev/null
@@ -0,0 +1,55 @@
+/**
+ * Copyright (c) 2010-2023 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.windcentrale.internal.dto;
+
+import java.util.Map;
+import java.util.Objects;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link ChallengeResponse} is the response of Cognito when starting user SRP authentication with a
+ * {@link InitiateAuthRequest}. It is answered using a {@link RespondToAuthChallengeRequest}.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class ChallengeResponse {
+
+    public String challengeName = "";
+    public Map<String, String> challengeParameters = Map.of();
+
+    private String getChallengeParameter(String key) {
+        return Objects.requireNonNullElse(challengeParameters.get(key), "");
+    }
+
+    public String getSalt() {
+        return getChallengeParameter("SALT");
+    }
+
+    public String getSecretBlock() {
+        return getChallengeParameter("SECRET_BLOCK");
+    }
+
+    public String getSrpB() {
+        return getChallengeParameter("SRP_B");
+    }
+
+    public String getUsername() {
+        return getChallengeParameter("USERNAME");
+    }
+
+    public String getUserIdForSrp() {
+        return getChallengeParameter("USER_ID_FOR_SRP");
+    }
+}
diff --git a/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/CognitoError.java b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/CognitoError.java
new file mode 100644 (file)
index 0000000..90d1c2d
--- /dev/null
@@ -0,0 +1,32 @@
+/**
+ * Copyright (c) 2010-2023 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.windcentrale.internal.dto;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * An error response of the Cognito API.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class CognitoError {
+
+    @SerializedName("__type")
+    public String type = "";
+
+    @SerializedName("message")
+    public String message = "";
+}
diff --git a/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/CognitoGson.java b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/CognitoGson.java
new file mode 100644 (file)
index 0000000..64f2f71
--- /dev/null
@@ -0,0 +1,31 @@
+/**
+ * Copyright (c) 2010-2023 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.windcentrale.internal.dto;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.google.gson.FieldNamingPolicy;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+
+/**
+ * The {@link CognitoGson} class provides a {@link Gson} instance configured for (de)serializing all Cognito data
+ * from/to JSON.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class CognitoGson {
+
+    public static final Gson GSON = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.UPPER_CAMEL_CASE).create();
+}
diff --git a/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/InitiateAuthRequest.java b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/InitiateAuthRequest.java
new file mode 100644 (file)
index 0000000..e39ee10
--- /dev/null
@@ -0,0 +1,51 @@
+/**
+ * Copyright (c) 2010-2023 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.windcentrale.internal.dto;
+
+import java.util.Map;
+import java.util.TreeMap;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link InitiateAuthRequest} can be used to start a Cognito user SRP authentication challenge or to refresh
+ * expired tokens using a refresh token.
+ *
+ * When starting user SRP authentication Cognito will respond with a {@link ChallengeResponse}.
+ * When refreshing expired tokens Cognito grants the new tokens in a {@link AuthenticationResultResponse}.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class InitiateAuthRequest {
+
+    public String authFlow = "";
+
+    public String clientId = "";
+
+    public Map<String, String> authParameters = new TreeMap<>();
+
+    InitiateAuthRequest(String authFlow, String clientId, Map<String, String> authParameters) {
+        this.authFlow = authFlow;
+        this.clientId = clientId;
+        this.authParameters.putAll(authParameters);
+    }
+
+    public static InitiateAuthRequest userSrpAuth(String clientId, String username, String srpA) {
+        return new InitiateAuthRequest("USER_SRP_AUTH", clientId, Map.of("USERNAME", username, "SRP_A", srpA));
+    }
+
+    public static InitiateAuthRequest refreshTokenAuth(String clientId, String refreshToken) {
+        return new InitiateAuthRequest("REFRESH_TOKEN_AUTH", clientId, Map.of("REFRESH_TOKEN", refreshToken));
+    }
+}
diff --git a/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/KeyResponse.java b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/KeyResponse.java
new file mode 100644 (file)
index 0000000..2a260a4
--- /dev/null
@@ -0,0 +1,30 @@
+/**
+ * Copyright (c) 2010-2023 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.windcentrale.internal.dto;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Provides the details required for getting tokens using SRP from the Windcentrale Cognito user pool.
+ *
+ * @see https://mijn.windcentrale.nl/api/v0/labels/key?domain=mijn.windcentrale.nl
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class KeyResponse {
+
+    public String clientId = "";
+    public String region = "";
+    public String userPoolId = "";
+}
diff --git a/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/Project.java b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/Project.java
new file mode 100644 (file)
index 0000000..f9794a5
--- /dev/null
@@ -0,0 +1,35 @@
+/**
+ * Copyright (c) 2010-2023 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.windcentrale.internal.dto;
+
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Maps a subset of the Windcentrale API project details that is required for discovering windmill things.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class Project {
+
+    public static final class Participation {
+        public int share;
+    }
+
+    public String projectCode = "";
+    public String projectName = "";
+
+    public List<Participation> participations = List.of();
+}
diff --git a/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/RespondToAuthChallengeRequest.java b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/RespondToAuthChallengeRequest.java
new file mode 100644 (file)
index 0000000..879ae7a
--- /dev/null
@@ -0,0 +1,38 @@
+/**
+ * Copyright (c) 2010-2023 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.windcentrale.internal.dto;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * The {@link RespondToAuthChallengeRequest} is sent to Cognito to respond to a user SRP {@link ChallengeResponse}.
+ * When the request is successful Cognito responds with a {@link AuthenticationResultResponse}.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+public class RespondToAuthChallengeRequest {
+
+    public String challengeName = "PASSWORD_VERIFIER";
+    public String clientId = "";
+    public Map<String, String> challengeResponses = new LinkedHashMap<>();
+
+    public RespondToAuthChallengeRequest(String clientId, String username, String passwordClaimSecretBlock,
+            String passwordClaimSignature, String timestamp) {
+        this.clientId = clientId;
+        challengeResponses.put("USERNAME", username);
+        challengeResponses.put("PASSWORD_CLAIM_SECRET_BLOCK", passwordClaimSecretBlock);
+        challengeResponses.put("PASSWORD_CLAIM_SIGNATURE", passwordClaimSignature);
+        challengeResponses.put("TIMESTAMP", timestamp);
+    }
+}
diff --git a/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/WindcentraleGson.java b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/WindcentraleGson.java
new file mode 100644 (file)
index 0000000..392b467
--- /dev/null
@@ -0,0 +1,84 @@
+/**
+ * Copyright (c) 2010-2023 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.windcentrale.internal.dto;
+
+import java.lang.reflect.Type;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.List;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+import com.google.gson.FieldNamingPolicy;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonDeserializationContext;
+import com.google.gson.JsonDeserializer;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonParseException;
+import com.google.gson.JsonPrimitive;
+import com.google.gson.JsonSerializationContext;
+import com.google.gson.JsonSerializer;
+import com.google.gson.reflect.TypeToken;
+
+/**
+ * The {@link WindcentraleGson} class provides a {@link Gson} instance configured for (de)serializing all Windcentrale
+ * data from/to JSON.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class WindcentraleGson {
+
+    public static final Gson GSON = new GsonBuilder()
+            .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
+            .registerTypeAdapter(Windmill.class, new WindmillConverter())
+            .registerTypeAdapter(ZonedDateTime.class, new ZonedDateTimeConverter()) //
+            .create();
+
+    public static final Type LIVE_DATA_RESPONSE_TYPE = new TypeToken<Map<Windmill, WindmillStatus>>() {
+    }.getType();
+
+    public static final Type PROJECTS_RESPONSE_TYPE = new TypeToken<List<Project>>() {
+    }.getType();
+
+    private static class WindmillConverter implements JsonSerializer<Windmill>, JsonDeserializer<Windmill> {
+        @Override
+        public JsonElement serialize(Windmill src, Type typeOfSrc, JsonSerializationContext context) {
+            return new JsonPrimitive(src.getProjectCode());
+        }
+
+        @Override
+        public @Nullable Windmill deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
+                throws JsonParseException {
+            return Windmill.fromProjectCode(json.getAsString());
+        }
+    }
+
+    private static class ZonedDateTimeConverter
+            implements JsonSerializer<ZonedDateTime>, JsonDeserializer<ZonedDateTime> {
+        @Override
+        public JsonElement serialize(ZonedDateTime src, Type typeOfSrc, JsonSerializationContext context) {
+            return new JsonPrimitive(src.toEpochSecond());
+        }
+
+        @Override
+        public @Nullable ZonedDateTime deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
+                throws JsonParseException {
+            return ZonedDateTime.ofInstant(Instant.ofEpochSecond(json.getAsLong()), ZoneId.systemDefault());
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/Windmill.java b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/Windmill.java
new file mode 100644 (file)
index 0000000..d1a93bb
--- /dev/null
@@ -0,0 +1,133 @@
+/**
+ * Copyright (c) 2010-2023 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.windcentrale.internal.dto;
+
+import java.util.Arrays;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * Enumerates the Windcentrale windmills. The project codes are used in API requests and responses.
+ * The other details are used as Thing properties.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public enum Windmill {
+
+    DE_GROTE_GEERT(1, "WND-GG", "De Grote Geert", 9910, "Enercon E-70", 2008, "Delfzijl", "Groningen",
+            "53.280605, 6.955141", "https://www.windcentrale.nl/molens/de-grote-geert-2/"),
+    DE_JONGE_HELD(2, "WND-JH", "De Jonge Held", 10154, "Enercon E-70", 2008, "Delfzijl", "Groningen",
+            "53.277648, 6.954906", "https://www.windcentrale.nl/molens/de-jonge-held/"),
+    HET_RODE_HERT(31, "WND-RH", "Het Rode Hert", 6648, "Vestas V80", 2005, "Culemborg", "Gelderland",
+            "51.935831, 5.192109", "https://www.windcentrale.nl/molens/het-rode-hert/"),
+    DE_RANKE_ZWAAN(41, "WND-RZ", "De Ranke Zwaan", 6164, "Vestas V80", 2005, "Culemborg", "Gelderland",
+            "51.934915, 5.19989", "https://www.windcentrale.nl/molens/de-ranke-zwaan-2/"),
+    DE_WITTE_JUFFER(51, "WND-WJ", "De Witte Juffer", 5721, "Vestas V80", 2005, "Culemborg", "Gelderland",
+            "51.935178, 5.195860", "https://www.windcentrale.nl/molens/de-witte-juffer/"),
+    DE_BONTE_HEN(111, "WND-BH", "De Bonte Hen", 5579, "Vestas V52", 2009, "Burgerbrug", "Noord-Holland",
+            "52.757051, 4.684678", "https://www.windcentrale.nl/molens/de-bonte-hen-2/"),
+    DE_TROUWE_WACHTER(121, "WND-TW", "De Trouwe Wachter", 5602, "Vestas V52", 2009, "Burgerbrug", "Noord-Holland",
+            "52.758745, 4.686041", "https://www.windcentrale.nl/molens/de-trouwe-wachter-2/"),
+    DE_BLAUWE_REIGER(131, "WND-BR", "De Blauwe Reiger", 5534, "Vestas V52", 2009, "Burgerbrug", "Noord-Holland",
+            "52.760482, 4.687438", "https://www.windcentrale.nl/molens/de-blauwe-reiger/"),
+    DE_VIER_WINDEN(141, "WND-VW", "De Vier Winden", 5512, "Vestas V52", 2009, "Burgerbrug", "Noord-Holland",
+            "52.762219, 4.688837", "https://www.windcentrale.nl/molens/de-vier-winden-2/"),
+    DE_BOERENZWALUW(201, "WND-BZ", "De Boerenzwaluw", 3000, "Enercon E-44", 2015, "Burum", "Friesland",
+            "53.265572, 6.213929", "https://www.windcentrale.nl/molens/de-boerenzwaluw/"),
+    HET_VLIEGENDE_HERT(211, "WND-VH", "Het Vliegend Hert", 10000, "Lagerwey L82", 2019, "Rouveen", "Overijssel",
+            "52.595422, 6.223335", "https://www.windcentrale.nl/molens/het-vliegend-hert/");
+
+    private final int id;
+    private final String projectCode;
+    private final String name;
+    private final int totalShares;
+    private final String type;
+    private final int buildYear;
+    private final String municipality;
+    private final String province;
+    private final String coordinates;
+    private final String detailsUrl;
+
+    Windmill(int id, String projectCode, String name, int totalShares, String type, int buildYear, String municipality,
+            String province, String coordinates, String detailsUrl) {
+        this.id = id;
+        this.projectCode = projectCode;
+        this.name = name;
+        this.totalShares = totalShares;
+        this.type = type;
+        this.buildYear = buildYear;
+        this.municipality = municipality;
+        this.province = province;
+        this.coordinates = coordinates;
+        this.detailsUrl = detailsUrl;
+    }
+
+    public int getId() {
+        return id;
+    }
+
+    public String getProjectCode() {
+        return projectCode;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public int getTotalShares() {
+        return totalShares;
+    }
+
+    public String getType() {
+        return type;
+    }
+
+    public int getBuildYear() {
+        return buildYear;
+    }
+
+    public String getMunicipality() {
+        return municipality;
+    }
+
+    public String getProvince() {
+        return province;
+    }
+
+    public String getCoordinates() {
+        return coordinates;
+    }
+
+    public String getDetailsUrl() {
+        return detailsUrl;
+    }
+
+    @Override
+    public String toString() {
+        return name;
+    }
+
+    public static @Nullable Windmill fromName(String name) {
+        return Arrays.stream(values()) //
+                .filter(windmill -> windmill.name.equals(name)) //
+                .findFirst().orElse(null);
+    }
+
+    public static @Nullable Windmill fromProjectCode(String projectCode) {
+        return Arrays.stream(values()) //
+                .filter(windmill -> windmill.projectCode.equals(projectCode)) //
+                .findFirst().orElse(null);
+    }
+}
diff --git a/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/WindmillStatus.java b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/WindmillStatus.java
new file mode 100644 (file)
index 0000000..0fdad14
--- /dev/null
@@ -0,0 +1,52 @@
+/**
+ * Copyright (c) 2010-2023 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.windcentrale.internal.dto;
+
+import java.time.ZonedDateTime;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The live {@link WindmillStatus} provided by the Windcentrale API.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class WindmillStatus {
+
+    public int power;
+
+    public int powerPerShare;
+
+    public int powerPercentage;
+
+    public ZonedDateTime timestamp = ZonedDateTime.now();
+
+    public int totalRuntime;
+
+    public String windDirection = "";
+
+    public int windPower;
+
+    public int yearProduction;
+
+    public double yearRuntime;
+
+    @Override
+    public String toString() {
+        return "WindmillStatus [power=" + power + ", powerPerShare=" + powerPerShare + ", powerPercentage="
+                + powerPercentage + ", timestamp=" + timestamp + ", totalRuntime=" + totalRuntime + ", windDirection="
+                + windDirection + ", windPower=" + windPower + ", yearProduction=" + yearProduction + ", yearRuntime="
+                + yearRuntime + "]";
+    }
+}
diff --git a/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/exception/FailedGettingDataException.java b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/exception/FailedGettingDataException.java
new file mode 100644 (file)
index 0000000..d0870b6
--- /dev/null
@@ -0,0 +1,38 @@
+/**
+ * Copyright (c) 2010-2023 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.windcentrale.internal.exception;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * An error occurred while retrieving data from the Windcentrale API.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class FailedGettingDataException extends Exception {
+
+    private static final long serialVersionUID = 4494062464212681327L;
+
+    public FailedGettingDataException(String message) {
+        super(message);
+    }
+
+    public FailedGettingDataException(String message, Throwable cause) {
+        super(message, cause);
+    }
+
+    public FailedGettingDataException(Throwable cause) {
+        super(cause);
+    }
+}
diff --git a/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/exception/InvalidAccessTokenException.java b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/exception/InvalidAccessTokenException.java
new file mode 100644 (file)
index 0000000..1d02f84
--- /dev/null
@@ -0,0 +1,38 @@
+/**
+ * Copyright (c) 2010-2023 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.windcentrale.internal.exception;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The Cognito access token used with the Windcentrale API is invalid and could not be refreshed.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class InvalidAccessTokenException extends Exception {
+
+    private static final long serialVersionUID = 9066624337663085233L;
+
+    public InvalidAccessTokenException(Exception cause) {
+        super(cause);
+    }
+
+    public InvalidAccessTokenException(String message, Throwable cause) {
+        super(message, cause);
+    }
+
+    public InvalidAccessTokenException(String message) {
+        super(message);
+    }
+}
diff --git a/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/handler/WindcentraleAccountHandler.java b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/handler/WindcentraleAccountHandler.java
new file mode 100644 (file)
index 0000000..19b8da9
--- /dev/null
@@ -0,0 +1,156 @@
+/**
+ * Copyright (c) 2010-2023 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.windcentrale.internal.handler;
+
+import static java.util.function.Predicate.not;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.Future;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.windcentrale.internal.WindcentraleDiscoveryService;
+import org.openhab.binding.windcentrale.internal.api.RequestListener;
+import org.openhab.binding.windcentrale.internal.api.TokenProvider;
+import org.openhab.binding.windcentrale.internal.api.WindcentraleAPI;
+import org.openhab.binding.windcentrale.internal.config.AccountConfiguration;
+import org.openhab.binding.windcentrale.internal.exception.FailedGettingDataException;
+import org.openhab.binding.windcentrale.internal.exception.InvalidAccessTokenException;
+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.ThingStatusDetail;
+import org.openhab.core.thing.binding.BaseBridgeHandler;
+import org.openhab.core.thing.binding.ThingHandlerService;
+import org.openhab.core.types.Command;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link WindcentraleAccountHandler} provides the {@link WindcentraleAPI} instance used by the windmill handlers.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class WindcentraleAccountHandler extends BaseBridgeHandler {
+
+    private final Logger logger = LoggerFactory.getLogger(WindcentraleAccountHandler.class);
+
+    private final HttpClientFactory httpClientFactory;
+
+    private @Nullable WindcentraleAPI api;
+    private @Nullable Exception apiException;
+    private @Nullable Future<?> initializeFuture;
+
+    private final RequestListener requestListener = new RequestListener() {
+        @Override
+        public void onError(Exception exception) {
+            apiException = exception;
+            logger.debug("API exception occurred");
+            updateThingStatus();
+        }
+
+        @Override
+        public void onSuccess() {
+            if (apiException != null) {
+                apiException = null;
+                logger.debug("API exception cleared");
+                updateThingStatus();
+            }
+        }
+    };
+
+    public WindcentraleAccountHandler(Bridge bridge, HttpClientFactory httpClientFactory) {
+        super(bridge);
+        this.httpClientFactory = httpClientFactory;
+    }
+
+    @Override
+    public void dispose() {
+        Future<?> localFuture = initializeFuture;
+        if (localFuture != null) {
+            localFuture.cancel(true);
+            initializeFuture = null;
+        }
+
+        WindcentraleAPI localAPI = api;
+        if (localAPI != null) {
+            localAPI.dispose();
+            api = null;
+        }
+    }
+
+    public @Nullable WindcentraleAPI getAPI() {
+        return api;
+    }
+
+    @Override
+    public void initialize() {
+        updateStatus(ThingStatus.UNKNOWN);
+
+        initializeFuture = scheduler.submit(() -> {
+            api = initializeAPI();
+            updateThingStatus();
+        });
+    }
+
+    private WindcentraleAPI initializeAPI() {
+        AccountConfiguration config = getConfigAs(AccountConfiguration.class);
+        TokenProvider tokenProvider = new TokenProvider(httpClientFactory, config.username, config.password);
+
+        WindcentraleAPI api = new WindcentraleAPI(httpClientFactory, tokenProvider);
+        api.addRequestListener(requestListener);
+        apiException = null;
+
+        try {
+            api.getProjects();
+            api.getLiveData();
+        } catch (FailedGettingDataException | InvalidAccessTokenException e) {
+            apiException = e;
+        }
+        return api;
+    }
+
+    @Override
+    public Collection<Class<? extends ThingHandlerService>> getServices() {
+        return List.of(WindcentraleDiscoveryService.class);
+    }
+
+    @Override
+    public void handleCommand(ChannelUID channelUID, Command command) {
+    }
+
+    private void updateThingStatus() {
+        Exception e = apiException;
+        if (e != null) {
+            if (e instanceof InvalidAccessTokenException) {
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
+            } else {
+                Throwable cause = e.getCause();
+                String description = Stream
+                        .of(Objects.requireNonNullElse(e.getMessage(), ""),
+                                cause == null ? "" : Objects.requireNonNullElse(cause.getMessage(), ""))
+                        .filter(not(String::isBlank)) //
+                        .collect(Collectors.joining(": "));
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, description);
+            }
+        } else {
+            updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE);
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/handler/WindcentraleHandler.java b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/handler/WindcentraleHandler.java
deleted file mode 100644 (file)
index da31c31..0000000
+++ /dev/null
@@ -1,166 +0,0 @@
-/**
- * Copyright (c) 2010-2023 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.windcentrale.internal.handler;
-
-import static org.openhab.binding.windcentrale.internal.WindcentraleBindingConstants.*;
-import static org.openhab.core.library.unit.MetricPrefix.KILO;
-
-import java.io.IOException;
-import java.math.BigDecimal;
-import java.util.concurrent.ScheduledFuture;
-import java.util.concurrent.TimeUnit;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-import org.openhab.binding.windcentrale.internal.config.MillConfig;
-import org.openhab.core.cache.ExpiringCache;
-import org.openhab.core.io.net.http.HttpUtil;
-import org.openhab.core.library.types.DateTimeType;
-import org.openhab.core.library.types.DecimalType;
-import org.openhab.core.library.types.QuantityType;
-import org.openhab.core.library.types.StringType;
-import org.openhab.core.library.unit.Units;
-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.types.Command;
-import org.openhab.core.types.RefreshType;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import com.google.gson.JsonElement;
-import com.google.gson.JsonObject;
-import com.google.gson.JsonParseException;
-import com.google.gson.JsonParser;
-
-/**
- * The {@link WindcentraleHandler} is responsible for handling commands, which are
- * sent to one of the channels.
- *
- * @author Marcel Verpaalen - Initial contribution
- * @author Wouter Born - Add null annotations
- */
-@NonNullByDefault
-public class WindcentraleHandler extends BaseThingHandler {
-
-    private static final String HOURS_RUN_THIS_YEAR = "hoursRunThisYear";
-    private static final String URL_FORMAT = "https://zep-api.windcentrale.nl/production/%d/live?ignoreLoadingBar=true";
-    private static final long CACHE_EXPIRY = TimeUnit.SECONDS.toMillis(5);
-
-    private final Logger logger = LoggerFactory.getLogger(WindcentraleHandler.class);
-
-    private @Nullable MillConfig millConfig;
-    private @Nullable String millUrl;
-    private @Nullable ScheduledFuture<?> pollingJob;
-
-    private final ExpiringCache<@Nullable String> windcentraleCache = new ExpiringCache<>(CACHE_EXPIRY, () -> {
-        try {
-            return millUrl != null ? HttpUtil.executeUrl("GET", millUrl, 5000) : null;
-        } catch (IOException e) {
-            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
-            return null;
-        }
-    });
-
-    public WindcentraleHandler(Thing thing) {
-        super(thing);
-    }
-
-    @Override
-    public void handleCommand(ChannelUID channelUID, Command command) {
-        if (command == RefreshType.REFRESH) {
-            logger.debug("Refreshing {}", channelUID);
-            updateData();
-        } else {
-            logger.debug("This binding is a read-only binding and cannot handle commands");
-        }
-    }
-
-    @Override
-    public void initialize() {
-        logger.debug("Initializing Windcentrale handler '{}'", getThing().getUID());
-
-        final MillConfig config = getConfig().as(MillConfig.class);
-
-        millConfig = config;
-        millUrl = String.format(URL_FORMAT, config.millId);
-        pollingJob = scheduler.scheduleWithFixedDelay(this::updateData, 0, config.refreshInterval, TimeUnit.SECONDS);
-
-        logger.debug("Polling job scheduled to run every {} sec. for '{}'", config.refreshInterval,
-                getThing().getUID());
-
-        updateProperty(Thing.PROPERTY_VENDOR, "Windcentrale");
-        updateProperty(Thing.PROPERTY_MODEL_ID, "Windmolen");
-        updateProperty(Thing.PROPERTY_SERIAL_NUMBER, Integer.toString(config.millId));
-    }
-
-    @Override
-    public void dispose() {
-        logger.debug("Disposing Windcentrale handler '{}'", getThing().getUID());
-        final ScheduledFuture<?> pollingJob = this.pollingJob;
-        if (pollingJob != null) {
-            pollingJob.cancel(true);
-            this.pollingJob = null;
-        }
-    }
-
-    private synchronized void updateData() {
-        try {
-            logger.debug("Update windmill data '{}'", getThing().getUID());
-
-            final MillConfig config = millConfig;
-            final String rawMillData = windcentraleCache.getValue();
-
-            if (config == null || rawMillData == null) {
-                return;
-            }
-            logger.trace("Retrieved updated mill data: {}", rawMillData);
-            final JsonElement jsonElement = JsonParser.parseString(rawMillData);
-
-            if (!(jsonElement instanceof JsonObject)) {
-                throw new JsonParseException("Could not parse windmill json data");
-            }
-            final JsonObject millData = (JsonObject) jsonElement;
-
-            updateState(CHANNEL_WIND_SPEED, new DecimalType(millData.get(CHANNEL_WIND_SPEED).getAsString()));
-            updateState(CHANNEL_WIND_DIRECTION, new StringType(millData.get(CHANNEL_WIND_DIRECTION).getAsString()));
-            updateState(CHANNEL_POWER_TOTAL,
-                    new QuantityType<>(millData.get(CHANNEL_POWER_TOTAL).getAsBigDecimal(), KILO(Units.WATT)));
-            updateState(CHANNEL_POWER_PER_WD,
-                    new QuantityType<>(
-                            millData.get(CHANNEL_POWER_PER_WD).getAsBigDecimal().multiply(new BigDecimal(config.wd)),
-                            Units.WATT));
-            updateState(CHANNEL_POWER_RELATIVE,
-                    new QuantityType<>(millData.get(CHANNEL_POWER_RELATIVE).getAsBigDecimal(), Units.PERCENT));
-            updateState(CHANNEL_ENERGY,
-                    new QuantityType<>(millData.get(CHANNEL_ENERGY).getAsBigDecimal(), Units.KILOWATT_HOUR));
-            updateState(CHANNEL_ENERGY_FC,
-                    new QuantityType<>(millData.get(CHANNEL_ENERGY_FC).getAsBigDecimal(), Units.KILOWATT_HOUR));
-            updateState(CHANNEL_RUNTIME,
-                    new QuantityType<>(millData.get(HOURS_RUN_THIS_YEAR).getAsBigDecimal(), Units.HOUR));
-            updateState(CHANNEL_RUNTIME_PER,
-                    new QuantityType<>(millData.get(CHANNEL_RUNTIME_PER).getAsBigDecimal(), Units.PERCENT));
-            updateState(CHANNEL_LAST_UPDATE, new DateTimeType(millData.get(CHANNEL_LAST_UPDATE).getAsString()));
-
-            if (!getThing().getStatus().equals(ThingStatus.ONLINE)) {
-                updateStatus(ThingStatus.ONLINE);
-            }
-        } catch (final RuntimeException e) {
-            logger.debug("Failed to process windmill data", e);
-            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
-                    "@text/offline.mill-data-error");
-        }
-    }
-}
diff --git a/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/handler/WindcentraleWindmillHandler.java b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/handler/WindcentraleWindmillHandler.java
new file mode 100644 (file)
index 0000000..7eddadc
--- /dev/null
@@ -0,0 +1,198 @@
+/**
+ * Copyright (c) 2010-2023 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.windcentrale.internal.handler;
+
+import static org.openhab.binding.windcentrale.internal.WindcentraleBindingConstants.*;
+import static org.openhab.core.library.unit.MetricPrefix.KILO;
+
+import java.math.BigDecimal;
+import java.time.Duration;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.windcentrale.internal.api.WindcentraleAPI;
+import org.openhab.binding.windcentrale.internal.config.WindmillConfiguration;
+import org.openhab.binding.windcentrale.internal.dto.Windmill;
+import org.openhab.binding.windcentrale.internal.dto.WindmillStatus;
+import org.openhab.binding.windcentrale.internal.exception.FailedGettingDataException;
+import org.openhab.binding.windcentrale.internal.exception.InvalidAccessTokenException;
+import org.openhab.core.cache.ExpiringCache;
+import org.openhab.core.library.types.DateTimeType;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.library.unit.Units;
+import org.openhab.core.thing.Bridge;
+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.types.Command;
+import org.openhab.core.types.RefreshType;
+import org.openhab.core.types.UnDefType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link WindcentraleWindmillHandler} is responsible for handling commands, which are
+ * sent to one of the channels.
+ *
+ * @author Marcel Verpaalen - Initial contribution
+ * @author Wouter Born - Add null annotations
+ * @author Wouter Born - Add support for new API with authentication
+ */
+@NonNullByDefault
+public class WindcentraleWindmillHandler extends BaseThingHandler {
+
+    private static final long CACHE_EXPIRY = TimeUnit.SECONDS.toMillis(5);
+
+    private final Logger logger = LoggerFactory.getLogger(WindcentraleWindmillHandler.class);
+
+    private @NonNullByDefault({}) WindmillConfiguration config;
+    private @Nullable Windmill windmill;
+
+    private @Nullable ScheduledFuture<?> pollingJob;
+
+    private final ExpiringCache<@Nullable WindmillStatus> statusCache = new ExpiringCache<>(CACHE_EXPIRY, () -> {
+        try {
+            WindcentraleAPI api = getAPI();
+            Windmill windmill = this.windmill;
+            return api == null || windmill == null ? null : api.getLiveData(windmill);
+        } catch (FailedGettingDataException | InvalidAccessTokenException e) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
+            return null;
+        }
+    });
+
+    public WindcentraleWindmillHandler(Thing thing) {
+        super(thing);
+    }
+
+    @Override
+    public void dispose() {
+        logger.debug("Disposing Windcentrale handler '{}'", getThing().getUID());
+        final ScheduledFuture<?> pollingJob = this.pollingJob;
+        if (pollingJob != null) {
+            pollingJob.cancel(true);
+            this.pollingJob = null;
+        }
+    }
+
+    protected @Nullable WindcentraleAPI getAPI() {
+        Bridge bridge = getBridge();
+        if (bridge == null) {
+            return null;
+        }
+        WindcentraleAccountHandler accountHandler = ((WindcentraleAccountHandler) bridge.getHandler());
+        return accountHandler == null ? null : accountHandler.getAPI();
+    }
+
+    @Override
+    public void handleCommand(ChannelUID channelUID, Command command) {
+        if (command == RefreshType.REFRESH) {
+            logger.debug("Refreshing {}", channelUID);
+            updateData();
+        } else {
+            logger.debug("This binding is a read-only binding and cannot handle commands");
+        }
+    }
+
+    @Override
+    public void initialize() {
+        logger.debug("Initializing Windcentrale handler '{}'", getThing().getUID());
+
+        WindmillConfiguration config = getConfig().as(WindmillConfiguration.class);
+        this.config = config;
+
+        Windmill windmill = Windmill.fromName(config.name);
+        this.windmill = windmill;
+
+        if (windmill == null) {
+            // only occurs when a mismatch is introduced between config parameter options and enum values
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
+                    "Invalid windmill name: " + config.name);
+            return;
+        }
+
+        updateProperties(getWindmillProperties(windmill));
+        updateStatus(ThingStatus.UNKNOWN);
+
+        pollingJob = scheduler.scheduleWithFixedDelay(this::updateData, 0, config.refreshInterval, TimeUnit.SECONDS);
+        logger.debug("Polling job scheduled to run every {} sec. for '{}'", config.refreshInterval,
+                getThing().getUID());
+    }
+
+    public static Map<String, String> getWindmillProperties(Windmill windmill) {
+        Map<String, String> properties = new HashMap<>();
+
+        properties.put(Thing.PROPERTY_VENDOR, "Windcentrale");
+        properties.put(Thing.PROPERTY_MODEL_ID, windmill.getType());
+        properties.put(Thing.PROPERTY_SERIAL_NUMBER, Integer.toString(windmill.getId()));
+
+        properties.put(PROPERTY_PROJECT_CODE, windmill.getProjectCode());
+        properties.put(PROPERTY_TOTAL_SHARES, Integer.toString(windmill.getTotalShares()));
+        properties.put(PROPERTY_BUILD_YEAR, Integer.toString(windmill.getBuildYear()));
+        properties.put(PROPERTY_MUNICIPALITY, windmill.getMunicipality());
+        properties.put(PROPERTY_PROVINCE, windmill.getProvince());
+        properties.put(PROPERTY_COORDINATES, windmill.getCoordinates());
+        properties.put(PROPERTY_DETAILS_URL, windmill.getDetailsUrl());
+
+        return properties;
+    }
+
+    private double yearRuntimePercentage(double yearRuntime) {
+        ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Europe/Amsterdam"));
+        ZonedDateTime startOfThisYear = now.withDayOfMonth(1).withMonth(1).truncatedTo(ChronoUnit.DAYS);
+        long hoursThisYear = Duration.between(startOfThisYear, now).toHours();
+        // prevent divide by zero when the year has just started
+        return 100 * (hoursThisYear > 0 ? yearRuntime / hoursThisYear : 1);
+    }
+
+    private synchronized void updateData() {
+        logger.debug("Updating windmill data '{}'", getThing().getUID());
+
+        WindmillStatus status = statusCache.getValue();
+        if (status == null) {
+            return;
+        }
+
+        logger.trace("Retrieved updated windmill status: {}", status);
+
+        updateState(CHANNEL_ENERGY_TOTAL, new QuantityType<>(status.yearProduction, Units.KILOWATT_HOUR));
+        updateState(CHANNEL_POWER_RELATIVE, new QuantityType<>(status.powerPercentage, Units.PERCENT));
+        updateState(CHANNEL_POWER_SHARES, new QuantityType<>(
+                new BigDecimal(status.powerPerShare).multiply(new BigDecimal(config.shares)), Units.WATT));
+        updateState(CHANNEL_POWER_TOTAL, new QuantityType<>(status.power, KILO(Units.WATT)));
+        updateState(CHANNEL_RUN_PERCENTAGE,
+                status.yearRuntime >= 0 ? new QuantityType<>(yearRuntimePercentage(status.yearRuntime), Units.PERCENT)
+                        : UnDefType.UNDEF);
+        updateState(CHANNEL_RUN_TIME,
+                status.yearRuntime >= 0 ? new QuantityType<>(new BigDecimal(status.yearRuntime), Units.HOUR)
+                        : UnDefType.UNDEF);
+        updateState(CHANNEL_WIND_DIRECTION, new StringType(status.windDirection));
+        updateState(CHANNEL_WIND_SPEED, new DecimalType(status.windPower));
+        updateState(CHANNEL_TIMESTAMP, new DateTimeType(status.timestamp));
+
+        if (!ThingStatus.ONLINE.equals(getThing().getStatus())) {
+            updateStatus(ThingStatus.ONLINE);
+        }
+    }
+}
index 4a1603dfe52c3ae0fd4123c454ec3eac44ec0cb1..8bca8666b9f5366af33993fe0106629673f68af2 100644 (file)
@@ -6,5 +6,7 @@
        <type>binding</type>
        <name>Windcentrale Binding</name>
        <description>Binding for Windcentrale windmills</description>
+       <connection>cloud</connection>
+       <countries>nl</countries>
 
 </addon:addon>
index f7e795c08b301592e6665f47b9965f35f2d1854e..0cf1e57f4c23de9c00a0e03eb2763751d4a5d177 100644 (file)
@@ -5,41 +5,31 @@ addon.windcentrale.description = Binding for Windcentrale windmills
 
 # thing types
 
-thing-type.windcentrale.mill.label = Windcentrale Windmill
+thing-type.windcentrale.account.label = Windcentrale Account
+thing-type.windcentrale.account.description = An account for using the Windcentrale API
+thing-type.windcentrale.windmill.label = Windcentrale Windmill
 
 # thing types config
 
-thing-type.config.windcentrale.mill.millId.label = Windmill
-thing-type.config.windcentrale.mill.millId.option.1 = De Grote Geert
-thing-type.config.windcentrale.mill.millId.option.2 = De Jonge Held
-thing-type.config.windcentrale.mill.millId.option.31 = Het Rode Hert
-thing-type.config.windcentrale.mill.millId.option.41 = De Ranke Zwaan
-thing-type.config.windcentrale.mill.millId.option.51 = De Witte Juffer
-thing-type.config.windcentrale.mill.millId.option.111 = De Bonte Hen
-thing-type.config.windcentrale.mill.millId.option.121 = De Trouwe Wachter
-thing-type.config.windcentrale.mill.millId.option.131 = De Blauwe Reiger
-thing-type.config.windcentrale.mill.millId.option.141 = De Vier Winden
-thing-type.config.windcentrale.mill.millId.option.201 = De Boerenzwaluw
-thing-type.config.windcentrale.mill.refreshInterval.label = Refresh Interval
-thing-type.config.windcentrale.mill.refreshInterval.description = Refresh interval for refreshing the data in seconds
-thing-type.config.windcentrale.mill.wd.label = Wind Shares
-thing-type.config.windcentrale.mill.wd.description = Number of wind shares ("Winddelen")
+thing-type.config.windcentrale.account.password.label = Password
+thing-type.config.windcentrale.account.username.label = Username
+thing-type.config.windcentrale.windmill.name.label = Windmill
+thing-type.config.windcentrale.windmill.refreshInterval.label = Refresh Interval
+thing-type.config.windcentrale.windmill.refreshInterval.description = Refresh interval for refreshing the data in seconds
+thing-type.config.windcentrale.windmill.shares.label = Wind Shares
+thing-type.config.windcentrale.windmill.shares.description = Number of wind shares ("Winddelen")
 
 # channel types
 
-channel-type.windcentrale.kwh.label = Energy
-channel-type.windcentrale.kwhForecast.label = Energy Forecast
-channel-type.windcentrale.powerAbsTot.label = Total Power
-channel-type.windcentrale.powerAbsWd.label = Wind Shares Power
-channel-type.windcentrale.powerRel.label = Relative Power
-channel-type.windcentrale.runPercentage.label = Run Percentage
-channel-type.windcentrale.runPercentage.description = Run percentage this year
-channel-type.windcentrale.runTime.label = Run Time
-channel-type.windcentrale.runTime.description = Run time this year
+channel-type.windcentrale.energy-total.label = Total Energy
+channel-type.windcentrale.energy-total.description = Energy produced this year
+channel-type.windcentrale.power-relative.label = Relative Power
+channel-type.windcentrale.power-shares.label = Wind Shares Power
+channel-type.windcentrale.power-total.label = Total Power
+channel-type.windcentrale.run-percentage.label = Run Percentage
+channel-type.windcentrale.run-percentage.description = Run percentage this year
+channel-type.windcentrale.run-time.label = Run Time
+channel-type.windcentrale.run-time.description = Run time this year
 channel-type.windcentrale.timestamp.label = Last Updated
-channel-type.windcentrale.windDirection.label = Wind Direction
-channel-type.windcentrale.windSpeed.label = Wind Speed
-
-# status messages
-
-offline.mill-data-error = Failed to process mill data
+channel-type.windcentrale.wind-direction.label = Wind Direction
+channel-type.windcentrale.wind-speed.label = Wind Speed
index da938e5a885d7aff325b4c50c04976976801980c..00d45020959cf2a57fcc870d7914ee4e8363af43 100644 (file)
@@ -5,41 +5,31 @@ addon.windcentrale.description = Binding voor Windcentrale windmolens
 
 # thing types
 
-thing-type.windcentrale.mill.label = Windcentrale windmolen
+thing-type.windcentrale.account.label = Windcentrale Account
+thing-type.windcentrale.account.description = Een account voor het gebruik van de Windcentrale API
+thing-type.windcentrale.windmill.label = Windcentrale windmolen
 
 # thing types config
 
-thing-type.config.windcentrale.mill.millId.label = Windmolen
-thing-type.config.windcentrale.mill.millId.option.1 = De Grote Geert
-thing-type.config.windcentrale.mill.millId.option.2 = De Jonge Held
-thing-type.config.windcentrale.mill.millId.option.31 = Het Rode Hert
-thing-type.config.windcentrale.mill.millId.option.41 = De Ranke Zwaan
-thing-type.config.windcentrale.mill.millId.option.51 = De Witte Juffer
-thing-type.config.windcentrale.mill.millId.option.111 = De Bonte Hen
-thing-type.config.windcentrale.mill.millId.option.121 = De Trouwe Wachter
-thing-type.config.windcentrale.mill.millId.option.131 = De Blauwe Reiger
-thing-type.config.windcentrale.mill.millId.option.141 = De Vier Winden
-thing-type.config.windcentrale.mill.millId.option.201 = De Boerenzwaluw
-thing-type.config.windcentrale.mill.refreshInterval.label = Ververs interval
-thing-type.config.windcentrale.mill.refreshInterval.description = Ververs interval in seconden
-thing-type.config.windcentrale.mill.wd.label = Aantal Winddelen
-thing-type.config.windcentrale.mill.wd.description = Aantal Winddelen in bezit
+thing-type.config.windcentrale.account.password.label = Wachtwoord
+thing-type.config.windcentrale.account.username.label = Gebruikersnaam
+thing-type.config.windcentrale.windmill.name.label = Windmolen
+thing-type.config.windcentrale.windmill.refreshInterval.label = Ververs interval
+thing-type.config.windcentrale.windmill.refreshInterval.description = Ververs interval in seconden
+thing-type.config.windcentrale.windmill.shares.label = Aantal Winddelen
+thing-type.config.windcentrale.windmill.shares.description = Aantal Winddelen in bezit
 
 # channel types
 
-channel-type.windcentrale.kwh.label = Energie
-channel-type.windcentrale.kwhForecast.label = Energie Voorspelling
-channel-type.windcentrale.powerAbsTot.label = Totaal Vermogen
-channel-type.windcentrale.powerAbsWd.label = Winddelen Vermogen
-channel-type.windcentrale.powerRel.label = Relatief Vermogen
-channel-type.windcentrale.runPercentage.label = Operationeel Percentage
-channel-type.windcentrale.runPercentage.description = Het aantal procent van de tijd dat de molen operationeel is dit jaar
-channel-type.windcentrale.runTime.label = Operationeel Tijd
-channel-type.windcentrale.runTime.description = Het aantal uren dat de molen operationeel is dit jaar
+channel-type.windcentrale.energy-total.label = Totaal Energie
+channel-type.windcentrale.energy-total.description = De totale energie geproduceerd door de windmolen dit jaar
+channel-type.windcentrale.power-relative.label = Relatief Vermogen
+channel-type.windcentrale.power-shares.label = Winddelen Vermogen
+channel-type.windcentrale.power-total.label = Totaal Vermogen
+channel-type.windcentrale.run-percentage.label = Operationeel Percentage
+channel-type.windcentrale.run-percentage.description = Het aantal procent van de tijd dat de windmolen operationeel is dit jaar
+channel-type.windcentrale.run-time.label = Operationeel Tijd
+channel-type.windcentrale.run-time.description = Het aantal uren dat de windmolen operationeel is dit jaar
 channel-type.windcentrale.timestamp.label = Laatst Bijgewerkt
-channel-type.windcentrale.windDirection.label = Windrichting
-channel-type.windcentrale.windSpeed.label = Windkracht
-
-# status messages
-
-offline.mill-data-error = Fout bij het verwerken van de molen data
+channel-type.windcentrale.wind-direction.label = Windrichting
+channel-type.windcentrale.wind-speed.label = Windkracht
diff --git a/bundles/org.openhab.binding.windcentrale/src/main/resources/OH-INF/thing/account.xml b/bundles/org.openhab.binding.windcentrale/src/main/resources/OH-INF/thing/account.xml
new file mode 100644 (file)
index 0000000..f8e1180
--- /dev/null
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="windcentrale"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+       <bridge-type id="account">
+               <label>Windcentrale Account</label>
+               <description>An account for using the Windcentrale API</description>
+
+               <config-description>
+                       <parameter name="username" type="text" required="true">
+                               <label>Username</label>
+                       </parameter>
+                       <parameter name="password" type="text" required="true">
+                               <context>password</context>
+                               <label>Password</label>
+                       </parameter>
+               </config-description>
+       </bridge-type>
+
+</thing:thing-descriptions>
diff --git a/bundles/org.openhab.binding.windcentrale/src/main/resources/OH-INF/thing/millThing.xml b/bundles/org.openhab.binding.windcentrale/src/main/resources/OH-INF/thing/millThing.xml
deleted file mode 100644 (file)
index c83b0f0..0000000
+++ /dev/null
@@ -1,117 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<thing:thing-descriptions bindingId="windcentrale"
-       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
-       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
-
-       <thing-type id="mill">
-               <label>Windcentrale Windmill</label>
-
-               <channels>
-                       <channel id="windSpeed" typeId="windSpeed"/>
-                       <channel id="windDirection" typeId="windDirection"/>
-                       <channel id="powerAbsTot" typeId="powerAbsTot"/>
-                       <channel id="powerAbsWd" typeId="powerAbsWd"/>
-                       <channel id="powerRel" typeId="powerRel"/>
-                       <channel id="kwh" typeId="kwh"/>
-                       <channel id="kwhForecast" typeId="kwhForecast"/>
-                       <channel id="runPercentage" typeId="runPercentage"/>
-                       <channel id="runTime" typeId="runTime"/>
-                       <channel id="timestamp" typeId="timestamp"/>
-               </channels>
-
-               <properties>
-                       <property name="vendor">Windcentrale</property>
-               </properties>
-
-               <config-description>
-                       <parameter name="millId" type="integer" required="true">
-                               <label>Windmill</label>
-                               <options>
-                                       <option value="1">De Grote Geert</option>
-                                       <option value="2">De Jonge Held</option>
-                                       <option value="31">Het Rode Hert</option>
-                                       <option value="41">De Ranke Zwaan</option>
-                                       <option value="51">De Witte Juffer</option>
-                                       <option value="111">De Bonte Hen</option>
-                                       <option value="121">De Trouwe Wachter</option>
-                                       <option value="131">De Blauwe Reiger</option>
-                                       <option value="141">De Vier Winden</option>
-                                       <option value="201">De Boerenzwaluw</option>
-                               </options>
-                               <default>131</default>
-                       </parameter>
-                       <parameter name="wd" type="integer" required="false">
-                               <label>Wind Shares</label>
-                               <description>Number of wind shares ("Winddelen")</description>
-                               <default>1</default>
-                       </parameter>
-                       <parameter name="refreshInterval" type="integer" min="1" max="9999" required="false">
-                               <label>Refresh Interval</label>
-                               <description>Refresh interval for refreshing the data in seconds</description>
-                               <default>30</default>
-                               <advanced>true</advanced>
-                       </parameter>
-               </config-description>
-       </thing-type>
-
-       <channel-type id="windSpeed">
-               <item-type>Number</item-type>
-               <label>Wind Speed</label>
-               <category>Wind</category>
-               <state pattern="%d Bft" readOnly="true"/>
-       </channel-type>
-       <channel-type id="windDirection">
-               <item-type>String</item-type>
-               <label>Wind Direction</label>
-               <state pattern="%s" readOnly="true"/>
-       </channel-type>
-       <channel-type id="powerRel">
-               <item-type>Number:Dimensionless</item-type>
-               <label>Relative Power</label>
-               <category>Energy</category>
-               <state pattern="%.1f %unit%" readOnly="true"/>
-       </channel-type>
-       <channel-type id="runPercentage">
-               <item-type>Number:Dimensionless</item-type>
-               <label>Run Percentage</label>
-               <description>Run percentage this year</description>
-               <category>Energy</category>
-               <state pattern="%.1f %unit%" readOnly="true"/>
-       </channel-type>
-       <channel-type id="runTime">
-               <item-type>Number:Time</item-type>
-               <label>Run Time</label>
-               <description>Run time this year</description>
-               <state pattern="%.0f %unit%" readOnly="true"/>
-       </channel-type>
-       <channel-type id="powerAbsWd">
-               <item-type>Number:Power</item-type>
-               <label>Wind Shares Power</label>
-               <category>Energy</category>
-               <state pattern="%.1f %unit%" readOnly="true"/>
-       </channel-type>
-       <channel-type id="powerAbsTot">
-               <item-type>Number:Power</item-type>
-               <label>Total Power</label>
-               <category>Energy</category>
-               <state pattern="%.1f %unit%" readOnly="true"/>
-       </channel-type>
-       <channel-type id="kwh">
-               <item-type>Number:Energy</item-type>
-               <label>Energy</label>
-               <category>Energy</category>
-               <state pattern="%.0f %unit%" readOnly="true"/>
-       </channel-type>
-       <channel-type id="kwhForecast">
-               <item-type>Number:Energy</item-type>
-               <label>Energy Forecast</label>
-               <category>Energy</category>
-               <state pattern="%.0f %unit%" readOnly="true"/>
-       </channel-type>
-       <channel-type id="timestamp">
-               <item-type>DateTime</item-type>
-               <label>Last Updated</label>
-               <state readOnly="true"/>
-       </channel-type>
-</thing:thing-descriptions>
diff --git a/bundles/org.openhab.binding.windcentrale/src/main/resources/OH-INF/thing/windmill.xml b/bundles/org.openhab.binding.windcentrale/src/main/resources/OH-INF/thing/windmill.xml
new file mode 100644 (file)
index 0000000..90f410d
--- /dev/null
@@ -0,0 +1,119 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="windcentrale"
+       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+       xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+       xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+       <thing-type id="windmill">
+               <supported-bridge-type-refs>
+                       <bridge-type-ref id="account"/>
+               </supported-bridge-type-refs>
+
+               <label>Windcentrale Windmill</label>
+
+               <channels>
+                       <channel id="power-shares" typeId="power-shares"/>
+                       <channel id="power-total" typeId="power-total"/>
+                       <channel id="power-relative" typeId="power-relative"/>
+                       <channel id="energy-total" typeId="energy-total"/>
+                       <channel id="run-time" typeId="run-time"/>
+                       <channel id="run-percentage" typeId="run-percentage"/>
+                       <channel id="wind-speed" typeId="wind-speed"/>
+                       <channel id="wind-direction" typeId="wind-direction"/>
+                       <channel id="timestamp" typeId="timestamp"/>
+               </channels>
+
+               <properties>
+                       <property name="vendor">Windcentrale</property>
+               </properties>
+
+               <representation-property>projectCode</representation-property>
+
+               <config-description>
+                       <parameter name="name" type="text" required="true">
+                               <label>Windmill</label>
+                               <options>
+                                       <option value="De Blauwe Reiger">De Blauwe Reiger</option>
+                                       <option value="De Boerenzwaluw">De Boerenzwaluw</option>
+                                       <option value="De Bonte Hen">De Bonte Hen</option>
+                                       <option value="De Grote Geert">De Grote Geert</option>
+                                       <option value="De Jonge Held">De Jonge Held</option>
+                                       <option value="De Ranke Zwaan">De Ranke Zwaan</option>
+                                       <option value="De Trouwe Wachter">De Trouwe Wachter</option>
+                                       <option value="De Vier Winden">De Vier Winden</option>
+                                       <option value="De Witte Juffer">De Witte Juffer</option>
+                                       <option value="Het Rode Hert">Het Rode Hert</option>
+                                       <option value="Het Vliegend Hert">Het Vliegend Hert</option>
+                               </options>
+                               <default>De Blauwe Reiger</default>
+                       </parameter>
+                       <parameter name="shares" type="integer" min="0" max="11000" required="false">
+                               <label>Wind Shares</label>
+                               <description>Number of wind shares ("Winddelen")</description>
+                               <default>1</default>
+                       </parameter>
+                       <parameter name="refreshInterval" type="integer" min="1" max="9999" required="false">
+                               <label>Refresh Interval</label>
+                               <description>Refresh interval for refreshing the data in seconds</description>
+                               <default>30</default>
+                               <advanced>true</advanced>
+                       </parameter>
+               </config-description>
+       </thing-type>
+
+       <channel-type id="energy-total">
+               <item-type>Number:Energy</item-type>
+               <label>Total Energy</label>
+               <description>Energy produced this year</description>
+               <category>Energy</category>
+               <state pattern="%.0f %unit%" readOnly="true"/>
+       </channel-type>
+       <channel-type id="power-relative">
+               <item-type>Number:Dimensionless</item-type>
+               <label>Relative Power</label>
+               <category>Energy</category>
+               <state pattern="%.1f %unit%" readOnly="true"/>
+       </channel-type>
+       <channel-type id="power-shares">
+               <item-type>Number:Power</item-type>
+               <label>Wind Shares Power</label>
+               <category>Energy</category>
+               <state pattern="%.1f %unit%" readOnly="true"/>
+       </channel-type>
+       <channel-type id="power-total">
+               <item-type>Number:Power</item-type>
+               <label>Total Power</label>
+               <category>Energy</category>
+               <state pattern="%.1f %unit%" readOnly="true"/>
+       </channel-type>
+       <channel-type id="run-time">
+               <item-type>Number:Time</item-type>
+               <label>Run Time</label>
+               <description>Run time this year</description>
+               <state pattern="%.0f %unit%" readOnly="true"/>
+       </channel-type>
+       <channel-type id="run-percentage">
+               <item-type>Number:Dimensionless</item-type>
+               <label>Run Percentage</label>
+               <description>Run percentage this year</description>
+               <category>Energy</category>
+               <state pattern="%.1f %unit%" readOnly="true"/>
+       </channel-type>
+       <channel-type id="timestamp">
+               <item-type>DateTime</item-type>
+               <label>Last Updated</label>
+               <state readOnly="true"/>
+       </channel-type>
+       <channel-type id="wind-direction">
+               <item-type>String</item-type>
+               <label>Wind Direction</label>
+               <state pattern="%s" readOnly="true"/>
+       </channel-type>
+       <channel-type id="wind-speed">
+               <item-type>Number</item-type>
+               <label>Wind Speed</label>
+               <category>Wind</category>
+               <state pattern="%d Bft" readOnly="true"/>
+       </channel-type>
+
+</thing:thing-descriptions>
diff --git a/bundles/org.openhab.binding.windcentrale/src/test/java/org/openhab/binding/windcentrale/internal/dto/CognitoGsonTest.java b/bundles/org.openhab.binding.windcentrale/src/test/java/org/openhab/binding/windcentrale/internal/dto/CognitoGsonTest.java
new file mode 100644 (file)
index 0000000..38fb80f
--- /dev/null
@@ -0,0 +1,109 @@
+/**
+ * Copyright (c) 2010-2023 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.windcentrale.internal.dto;
+
+import static org.hamcrest.CoreMatchers.notNullValue;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+
+import java.io.IOException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests (de)serialization of AWS Cognito requests/responses to/from JSON.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class CognitoGsonTest {
+
+    private static final DataUtil DATA_UTIL = new DataUtil(CognitoGson.GSON);
+
+    @Test
+    public void serializeInitiateAuthRequestSrp() throws IOException {
+        String json = DATA_UTIL.toJson(InitiateAuthRequest.userSrpAuth("clientId123", "username456", "srpA789"));
+        assertThat(json, is(DATA_UTIL.fromFile("initiate-auth-request-srp.json")));
+    }
+
+    @Test
+    public void deserializeChallengeResponseSrp() throws IOException {
+        ChallengeResponse response = DATA_UTIL.fromJson("challenge-response-srp.json", ChallengeResponse.class);
+        assertThat(response, is(notNullValue()));
+
+        assertThat(response.challengeName, is("PASSWORD_VERIFIER"));
+        assertThat(response.getSalt(), is("salt123"));
+        assertThat(response.getSecretBlock(), is("secretBlock456"));
+        assertThat(response.getSrpB(), is("srpB789"));
+        assertThat(response.getUsername(), is("username@acme.com"));
+        assertThat(response.getUserIdForSrp(), is("userid@acme.com"));
+    }
+
+    @Test
+    public void serializeInitiateAuthRequestRefresh() throws IOException {
+        String json = DATA_UTIL.toJson(InitiateAuthRequest.refreshTokenAuth("clientId123", "refreshToken123"));
+        assertThat(json, is(DATA_UTIL.fromFile("initiate-auth-request-refresh.json")));
+    }
+
+    @Test
+    public void deserializeInitiateAuthResponseRefresh() throws IOException {
+        AuthenticationResultResponse response = DATA_UTIL.fromJson("authentication-result-response-refresh.json",
+                AuthenticationResultResponse.class);
+        assertThat(response, is(notNullValue()));
+
+        assertThat(response.getAccessToken(), is("accessToken123"));
+        assertThat(response.getExpiresIn(), is(3600));
+        assertThat(response.getIdToken(), is("idToken456"));
+        assertThat(response.getRefreshToken(), is(""));
+        assertThat(response.getTokenType(), is("Bearer"));
+    }
+
+    @Test
+    public void serializeRespondToAuthChallengeRequest() throws IOException {
+        String json = DATA_UTIL.toJson(new RespondToAuthChallengeRequest("clientId123", "username@acme.com",
+                "passwordClaimSecretBlock456", "passwordClaimSignature789", "Thu Apr 6 07:16:19 UTC 2023"));
+        assertThat(json, is(DATA_UTIL.fromFile("respond-to-auth-challenge-request.json")));
+    }
+
+    @Test
+    public void deserializeRespondToAuthChallengeResponse() throws IOException {
+        AuthenticationResultResponse response = DATA_UTIL.fromJson("authentication-result-response-challenge.json",
+                AuthenticationResultResponse.class);
+        assertThat(response, is(notNullValue()));
+
+        assertThat(response.getAccessToken(), is("accessToken123"));
+        assertThat(response.getExpiresIn(), is(3600));
+        assertThat(response.getIdToken(), is("idToken456"));
+        assertThat(response.getRefreshToken(), is("refreshToken789"));
+        assertThat(response.getTokenType(), is("Bearer"));
+    }
+
+    @Test
+    public void deserializeErrorResponseInvalidParameter() throws IOException {
+        CognitoError response = DATA_UTIL.fromJson("cognito-error-response-invalid-parameter.json", CognitoError.class);
+        assertThat(response, is(notNullValue()));
+
+        assertThat(response.type, is("InvalidParameterException"));
+        assertThat(response.message, is("Missing required parameter REFRESH_TOKEN"));
+    }
+
+    @Test
+    public void deserializeErrorResponseNotAuthorized() throws IOException {
+        CognitoError response = DATA_UTIL.fromJson("cognito-error-response-not-authorized.json", CognitoError.class);
+        assertThat(response, is(notNullValue()));
+
+        assertThat(response.type, is("NotAuthorizedException"));
+        assertThat(response.message, is("Incorrect username or password."));
+    }
+}
diff --git a/bundles/org.openhab.binding.windcentrale/src/test/java/org/openhab/binding/windcentrale/internal/dto/DataUtil.java b/bundles/org.openhab.binding.windcentrale/src/test/java/org/openhab/binding/windcentrale/internal/dto/DataUtil.java
new file mode 100644 (file)
index 0000000..b18d0ab
--- /dev/null
@@ -0,0 +1,74 @@
+/**
+ * Copyright (c) 2010-2023 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.windcentrale.internal.dto;
+
+import java.io.BufferedReader;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.io.StringWriter;
+import java.lang.reflect.Type;
+import java.nio.charset.StandardCharsets;
+import java.util.stream.Collectors;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.google.gson.Gson;
+import com.google.gson.stream.JsonWriter;
+
+/**
+ * Utility class for working with test data in unit tests.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class DataUtil {
+
+    private final Gson gson;
+
+    public DataUtil(Gson gson) {
+        this.gson = gson;
+    }
+
+    @SuppressWarnings("null")
+    public Reader openDataReader(String fileName) throws FileNotFoundException {
+        String packagePath = (DataUtil.class.getPackage().getName()).replaceAll("\\.", "/");
+        String filePath = "src/test/resources/" + packagePath + "/" + fileName;
+
+        InputStream inputStream = new FileInputStream(filePath);
+        return new InputStreamReader(inputStream, StandardCharsets.UTF_8);
+    }
+
+    public <T> T fromJson(String fileName, Type typeOfT) throws IOException {
+        try (Reader reader = openDataReader(fileName)) {
+            return gson.fromJson(reader, typeOfT);
+        }
+    }
+
+    public String fromFile(String fileName) throws IOException {
+        try (Reader reader = openDataReader(fileName)) {
+            return new BufferedReader(reader).lines().parallel().collect(Collectors.joining("\n"));
+        }
+    }
+
+    public String toJson(Object object) {
+        StringWriter writer = new StringWriter();
+        JsonWriter jsonWriter = new JsonWriter(writer);
+        jsonWriter.setIndent("  ");
+        gson.toJson(object, object.getClass(), jsonWriter);
+        return writer.toString();
+    }
+}
diff --git a/bundles/org.openhab.binding.windcentrale/src/test/java/org/openhab/binding/windcentrale/internal/dto/WindcentraleGsonTest.java b/bundles/org.openhab.binding.windcentrale/src/test/java/org/openhab/binding/windcentrale/internal/dto/WindcentraleGsonTest.java
new file mode 100644 (file)
index 0000000..2dc0fc0
--- /dev/null
@@ -0,0 +1,141 @@
+/**
+ * Copyright (c) 2010-2023 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.windcentrale.internal.dto;
+
+import static org.hamcrest.CoreMatchers.notNullValue;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.windcentrale.internal.dto.Project.Participation;
+
+/**
+ * Tests deserialization of Windcentrale API responses from JSON.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class WindcentraleGsonTest {
+
+    private static final DataUtil DATA_UTIL = new DataUtil(WindcentraleGson.GSON);
+
+    @Test
+    public void deserializeKeyResponse() throws IOException {
+        KeyResponse key = DATA_UTIL.fromJson("key-response.json", KeyResponse.class);
+        assertThat(key, is(notNullValue()));
+
+        assertThat(key.clientId, is("715j3r0trk7o8dqg3md57il7q0"));
+        assertThat(key.region, is("eu-west-1"));
+        assertThat(key.userPoolId, is("eu-west-1_U7eYBPrBd"));
+    }
+
+    @Test
+    public void deserializeProjectsResponse() throws IOException {
+        List<Project> projects = DATA_UTIL.fromJson("projects-response.json", WindcentraleGson.PROJECTS_RESPONSE_TYPE);
+
+        assertThat(projects, is(notNullValue()));
+        assertThat(projects.size(), is(1));
+
+        Project project = projects.get(0);
+
+        assertThat(project.projectName, is("De Grote Geert"));
+        assertThat(project.projectCode, is("WND-GG"));
+
+        List<Participation> participations = Objects.requireNonNull(project.participations);
+        assertThat(participations.size(), is(2));
+
+        assertThat(participations.get(0).share, is(20));
+        assertThat(participations.get(1).share, is(50));
+    }
+
+    @Test
+    public void deserializeLiveDataResponseEmpty() throws IOException {
+        Map<Windmill, WindmillStatus> map = DATA_UTIL.fromJson("live-data-response-empty.json",
+                WindcentraleGson.LIVE_DATA_RESPONSE_TYPE);
+
+        assertThat(map, is(notNullValue()));
+        assertThat(map.size(), is(0));
+    }
+
+    @Test
+    public void deserializeLiveDataResponseSingle() throws IOException {
+        Map<Windmill, WindmillStatus> map = DATA_UTIL.fromJson("live-data-response-single.json",
+                WindcentraleGson.LIVE_DATA_RESPONSE_TYPE);
+
+        assertThat(map, is(notNullValue()));
+        assertThat(map.size(), is(1));
+
+        assertDeJongeHeldStatus(map);
+    }
+
+    @Test
+    public void deserializeLiveDataResponseMultiple() throws IOException {
+        Map<Windmill, WindmillStatus> map = DATA_UTIL.fromJson("live-data-response-multiple.json",
+                WindcentraleGson.LIVE_DATA_RESPONSE_TYPE);
+
+        assertThat(map, is(notNullValue()));
+        assertThat(map.size(), is(11));
+
+        assertDeBlauweReigerStatus(map);
+        assertDeJongeHeldStatus(map);
+        assertDeWitteJufferStatus(map);
+    }
+
+    private void assertDeBlauweReigerStatus(Map<Windmill, WindmillStatus> map) {
+        WindmillStatus status = Objects.requireNonNull(map.get(Windmill.DE_BLAUWE_REIGER));
+
+        assertThat(status.powerPerShare, is(150));
+        assertThat(status.timestamp.toEpochSecond(), is(1680425425L));
+        assertThat(status.windPower, is(7));
+        assertThat(status.power, is(827));
+        assertThat(status.windDirection, is("O"));
+        assertThat(status.yearProduction, is(872488));
+        assertThat(status.totalRuntime, is(29470));
+        assertThat(status.yearRuntime, is(-98268833.015556d));
+        assertThat(status.powerPercentage, is(98));
+    }
+
+    private void assertDeJongeHeldStatus(Map<Windmill, WindmillStatus> map) {
+        WindmillStatus status = Objects.requireNonNull(map.get(Windmill.DE_JONGE_HELD));
+
+        assertThat(status.powerPerShare, is(52));
+        assertThat(status.timestamp.toEpochSecond(), is(1680425425L));
+        assertThat(status.windPower, is(5));
+        assertThat(status.power, is(522));
+        assertThat(status.windDirection, is("O"));
+        assertThat(status.yearProduction, is(1508090));
+        assertThat(status.totalRuntime, is(122330));
+        assertThat(status.yearRuntime, is(2089d));
+        assertThat(status.powerPercentage, is(23));
+    }
+
+    private void assertDeWitteJufferStatus(Map<Windmill, WindmillStatus> map) {
+        WindmillStatus status = Objects.requireNonNull(map.get(Windmill.DE_WITTE_JUFFER));
+
+        assertThat(status.powerPerShare, is(134));
+        assertThat(status.timestamp.toEpochSecond(), is(1680425425L));
+        assertThat(status.windPower, is(5));
+        assertThat(status.power, is(764));
+        assertThat(status.windDirection, is("NO"));
+        assertThat(status.yearProduction, is(1233164));
+        assertThat(status.totalRuntime, is(111171));
+        assertThat(status.yearRuntime, is(2118.266667d));
+        assertThat(status.powerPercentage, is(39));
+    }
+}
diff --git a/bundles/org.openhab.binding.windcentrale/src/test/java/org/openhab/binding/windcentrale/internal/dto/WindmillTest.java b/bundles/org.openhab.binding.windcentrale/src/test/java/org/openhab/binding/windcentrale/internal/dto/WindmillTest.java
new file mode 100644 (file)
index 0000000..a0dde3e
--- /dev/null
@@ -0,0 +1,70 @@
+/**
+ * Copyright (c) 2010-2023 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.windcentrale.internal.dto;
+
+import static org.hamcrest.CoreMatchers.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+import java.util.Arrays;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests the {@link Windmill} enum.
+ *
+ * @author Wouter Born - Initial contribution
+ */
+@NonNullByDefault
+public class WindmillTest {
+
+    @Test
+    public void fromName() {
+        assertThat(Windmill.fromName("Unknown Windmill"), nullValue());
+        assertThat(Windmill.fromName("De Grote Geert"), is(Windmill.DE_GROTE_GEERT));
+
+        for (Windmill windmill : Windmill.values()) {
+            assertThat(Windmill.fromName(windmill.getName()), is(windmill));
+        }
+    }
+
+    @Test
+    public void fromProjectCode() {
+        assertThat(Windmill.fromProjectCode("WND-UNKNOWN"), nullValue());
+        assertThat(Windmill.fromProjectCode("WND-GG"), is(Windmill.DE_GROTE_GEERT));
+
+        for (Windmill windmill : Windmill.values()) {
+            assertThat(Windmill.fromProjectCode(windmill.getProjectCode()), is(windmill));
+        }
+    }
+
+    @Test
+    public void namesAreUnique() {
+        int count = (int) Arrays.stream(Windmill.values()) //
+                .map(Windmill::getName) //
+                .distinct() //
+                .count();
+
+        assertThat(count, is(Windmill.values().length));
+    }
+
+    @Test
+    public void projectCodesAreUnique() {
+        int count = (int) Arrays.stream(Windmill.values()) //
+                .map(Windmill::getProjectCode) //
+                .distinct() //
+                .count();
+
+        assertThat(count, is(Windmill.values().length));
+    }
+}
diff --git a/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/authentication-result-response-challenge.json b/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/authentication-result-response-challenge.json
new file mode 100644 (file)
index 0000000..d3dd489
--- /dev/null
@@ -0,0 +1,12 @@
+{
+  "AuthenticationResult": {
+    "AccessToken": "accessToken123",
+    "ExpiresIn": 3600,
+    "IdToken": "idToken456",
+    "RefreshToken": "refreshToken789",
+    "TokenType": "Bearer"
+  },
+  "ChallengeParameters": {
+    
+  }
+}
diff --git a/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/authentication-result-response-refresh.json b/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/authentication-result-response-refresh.json
new file mode 100644 (file)
index 0000000..13b7992
--- /dev/null
@@ -0,0 +1,11 @@
+{
+  "AuthenticationResult": {
+    "AccessToken": "accessToken123",
+    "ExpiresIn": 3600,
+    "IdToken": "idToken456",
+    "TokenType": "Bearer"
+  },
+  "ChallengeParameters": {
+    
+  }
+}
diff --git a/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/challenge-response-srp.json b/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/challenge-response-srp.json
new file mode 100644 (file)
index 0000000..db41064
--- /dev/null
@@ -0,0 +1,10 @@
+{
+  "ChallengeName": "PASSWORD_VERIFIER",
+  "ChallengeParameters": {
+    "SALT": "salt123",
+    "SECRET_BLOCK": "secretBlock456",
+    "SRP_B": "srpB789",
+    "USERNAME": "username@acme.com",
+    "USER_ID_FOR_SRP": "userid@acme.com"
+  }
+}
diff --git a/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/cognito-error-response-invalid-parameter.json b/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/cognito-error-response-invalid-parameter.json
new file mode 100644 (file)
index 0000000..10d9b77
--- /dev/null
@@ -0,0 +1,4 @@
+{
+  "__type": "InvalidParameterException",
+  "message": "Missing required parameter REFRESH_TOKEN"
+}
diff --git a/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/cognito-error-response-not-authorized.json b/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/cognito-error-response-not-authorized.json
new file mode 100644 (file)
index 0000000..6f0022a
--- /dev/null
@@ -0,0 +1,4 @@
+{
+  "__type": "NotAuthorizedException",
+  "message": "Incorrect username or password."
+}
diff --git a/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/initiate-auth-request-refresh.json b/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/initiate-auth-request-refresh.json
new file mode 100644 (file)
index 0000000..2b69b28
--- /dev/null
@@ -0,0 +1,7 @@
+{
+  "AuthFlow": "REFRESH_TOKEN_AUTH",
+  "ClientId": "clientId123",
+  "AuthParameters": {
+    "REFRESH_TOKEN": "refreshToken123"
+  }
+}
diff --git a/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/initiate-auth-request-srp.json b/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/initiate-auth-request-srp.json
new file mode 100644 (file)
index 0000000..85c9d2c
--- /dev/null
@@ -0,0 +1,8 @@
+{
+  "AuthFlow": "USER_SRP_AUTH",
+  "ClientId": "clientId123",
+  "AuthParameters": {
+    "SRP_A": "srpA789",
+    "USERNAME": "username456"
+  }
+}
diff --git a/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/key-response.json b/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/key-response.json
new file mode 100644 (file)
index 0000000..33baa63
--- /dev/null
@@ -0,0 +1,14 @@
+{
+  "client_id": "715j3r0trk7o8dqg3md57il7q0",
+  "issuer": null,
+  "authorization_endpoint": null,
+  "logout_endpoint": null,
+  "revoke_token_endpoint": null,
+  "logout_redirect_url": "https://www.windcentrale.nl/",
+  "privacy_and_cookie_url": "http://docs.servicehouse.nl/windcentrale/algemeen_privacy_statement.pdf",
+  "label_key": "1024",
+  "ga_tracking_id": null,
+  "region": "eu-west-1",
+  "user_pool_id": "eu-west-1_U7eYBPrBd",
+  "cognito_active": true
+}
diff --git a/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/live-data-response-empty.json b/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/live-data-response-empty.json
new file mode 100644 (file)
index 0000000..0967ef4
--- /dev/null
@@ -0,0 +1 @@
+{}
diff --git a/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/live-data-response-multiple.json b/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/live-data-response-multiple.json
new file mode 100644 (file)
index 0000000..aafaca9
--- /dev/null
@@ -0,0 +1,167 @@
+{
+   "WND-RZ":{
+      "power_per_share":"118",
+      "id":"41",
+      "timestamp":"1680425425",
+      "wind_power":"5",
+      "power":"727",
+      "wind_direction":"NO",
+      "year_production":"1094409",
+      "rpm":"15.4",
+      "total_runtime":"22996",
+      "year_runtime":"1986.811111",
+      "diameter":"45",
+      "pulsating":"0",
+      "power_percentage":"37"
+   },
+   "WND-BR":{
+      "power_per_share":"150",
+      "id":"131",
+      "timestamp":"1680425425",
+      "wind_power":"7",
+      "power":"827",
+      "wind_direction":"O",
+      "year_production":"872488",
+      "rpm":"26.1",
+      "total_runtime":"29470",
+      "year_runtime":"-98268833.015556",
+      "diameter":"98",
+      "pulsating":"0",
+      "power_percentage":"98"
+   },
+   "WND-RH":{
+      "power_per_share":"75",
+      "id":"31",
+      "timestamp":"1680425425",
+      "wind_power":"5",
+      "power":"494",
+      "wind_direction":"NO",
+      "year_production":"0",
+      "rpm":"14.5",
+      "total_runtime":"0",
+      "year_runtime":"0",
+      "diameter":"37",
+      "pulsating":"0",
+      "power_percentage":"25"
+   },
+   "WND-WJ":{
+      "power_per_share":"134",
+      "id":"51",
+      "timestamp":"1680425425",
+      "wind_power":"5",
+      "power":"764",
+      "wind_direction":"NO",
+      "year_production":"1233164",
+      "rpm":"15.8",
+      "total_runtime":"111171",
+      "year_runtime":"2118.266667",
+      "diameter":"46",
+      "pulsating":"0",
+      "power_percentage":"39"
+   },
+   "WND-BZ":{
+      "power_per_share":"84",
+      "id":"201",
+      "timestamp":"1680425425",
+      "wind_power":"5",
+      "power":"251",
+      "wind_direction":"NO",
+      "year_production":"557790",
+      "rpm":"27.2",
+      "total_runtime":"55375",
+      "year_runtime":"1989.000000",
+      "diameter":"38",
+      "pulsating":"0",
+      "power_percentage":"28"
+   },
+   "WND-VW":{
+      "power_per_share":"148",
+      "id":"141",
+      "timestamp":"1680425425",
+      "wind_power":"7",
+      "power":"812",
+      "wind_direction":"O",
+      "year_production":"852820",
+      "rpm":"26.1",
+      "total_runtime":"118985",
+      "year_runtime":"-420428662.080000",
+      "diameter":"96",
+      "pulsating":"0",
+      "power_percentage":"96"
+   },
+   "WND-GG":{
+      "power_per_share":"31",
+      "id":"1",
+      "timestamp":"1680425425",
+      "wind_power":"4",
+      "power":"303",
+      "wind_direction":"NO",
+      "year_production":"1457297",
+      "rpm":"13.2",
+      "total_runtime":"122207",
+      "year_runtime":"2110.000000",
+      "diameter":"26",
+      "pulsating":"0",
+      "power_percentage":"14"
+   },
+   "WND-TW":{
+      "power_per_share":"141",
+      "id":"121",
+      "timestamp":"1680425425",
+      "wind_power":"6",
+      "power":"788",
+      "wind_direction":"NO",
+      "year_production":"0",
+      "rpm":"25.9",
+      "total_runtime":"0",
+      "year_runtime":"0",
+      "diameter":"93",
+      "pulsating":"0",
+      "power_percentage":"93"
+   },
+   "WND-BH":{
+      "power_per_share":"138",
+      "id":"111",
+      "timestamp":"1680425425",
+      "wind_power":"6",
+      "power":"768",
+      "wind_direction":"NO",
+      "year_production":"860296",
+      "rpm":"26.1",
+      "total_runtime":"117041",
+      "year_runtime":"-413559214.400000",
+      "diameter":"91",
+      "pulsating":"0",
+      "power_percentage":"91"
+   },
+   "WND-VH":{
+      "power_per_share":"105",
+      "id":"211",
+      "timestamp":"1680425425",
+      "wind_power":"5",
+      "power":"1020",
+      "wind_direction":"NO",
+      "year_production":"1322184",
+      "rpm":"16.7",
+      "total_runtime":"0",
+      "year_runtime":"0",
+      "diameter":"100",
+      "pulsating":"1",
+      "power_percentage":"113"
+   },
+   "WND-JH":{
+      "power_per_share":"52",
+      "id":"2",
+      "timestamp":"1680425425",
+      "wind_power":"5",
+      "power":"522",
+      "wind_direction":"O",
+      "year_production":"1508090",
+      "rpm":"14.9",
+      "total_runtime":"122330",
+      "year_runtime":"2089.000000",
+      "diameter":"35",
+      "pulsating":"0",
+      "power_percentage":"23"
+   }
+}
diff --git a/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/live-data-response-single.json b/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/live-data-response-single.json
new file mode 100644 (file)
index 0000000..e7275d1
--- /dev/null
@@ -0,0 +1,17 @@
+{
+   "WND-JH":{
+      "power_per_share":"52",
+      "id":"2",
+      "timestamp":"1680425425",
+      "wind_power":"5",
+      "power":"522",
+      "wind_direction":"O",
+      "year_production":"1508090",
+      "rpm":"14.9",
+      "total_runtime":"122330",
+      "year_runtime":"2089.000000",
+      "diameter":"35",
+      "pulsating":"0",
+      "power_percentage":"23"
+   }
+}
diff --git a/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/projects-response.json b/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/projects-response.json
new file mode 100644 (file)
index 0000000..a521593
--- /dev/null
@@ -0,0 +1,77 @@
+[
+  {
+    "project_name": "De Grote Geert",
+    "project_code": "WND-GG",
+    "project_type": "WIND",
+    "project_ean_number": "123456789012345678",
+    "project_start_date": "2013-01-01",
+    "project_end_date": "2030-09-01",
+    "energy_supplier": "GREENCHOICE",
+    "current_power": 100,
+    "power_percentage": 0,
+    "todays_production": 2000,
+    "total_production": 8000000,
+    "participations": [
+      {
+        "supplier": "Greenchoice",
+        "mutation_date": "2013-01-01",
+        "share": 20,
+        "offered_share": 0,
+        "participation_identifier": "WND-GG-1",
+        "greenchoice_id": "12345",
+        "address": {
+          "street_name": "Kerkstraat",
+          "house_number": "54321",
+          "house_postfix": null,
+          "postal_code": "9999XY",
+          "city": "Amsterdam"
+        },
+        "production": null,
+        "shares": [
+          {
+            "from_date": "2013-01-01",
+            "share": 20
+          }
+        ]
+      },
+      {
+        "supplier": "Greenchoice",
+        "mutation_date": "2022-10-09",
+        "share": 50,
+        "offered_share": 0,
+        "participation_identifier": "WND-GG-2",
+        "greenchoice_id": null,
+        "address": {
+          "street_name": "Kerkstraat",
+          "house_number": "54321",
+          "house_postfix": null,
+          "postal_code": "9999XY",
+          "city": "Amsterdam"
+        },
+        "production": null,
+        "shares": [
+          {
+            "from_date": "2022-10-09",
+            "share": 50
+          }
+        ]
+      }
+    ],
+    "member_offer_settings": [
+      {
+        "from_date": "2021-10-21",
+        "allow_selling": true,
+        "min_price": 0,
+        "max_price": 200
+      }
+    ],
+    "administration_costs": [
+      {
+        "from_date": "2019-09-01",
+        "amount": 15
+      }
+    ],
+    "statutes_location": "https://cdn.servicehouse.nl/cooperation/20120614%20De%20Grote%20Geert%20UA%20Akte%20van%20Oprichting.pdf",
+    "member_agreement_location": "https://cdn.servicehouse.nl/cooperation/leden_en_winddelenovereenkomst_Grote_Geert_v1.pdf"
+  }
+]
diff --git a/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/respond-to-auth-challenge-request.json b/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/respond-to-auth-challenge-request.json
new file mode 100644 (file)
index 0000000..0dc9114
--- /dev/null
@@ -0,0 +1,10 @@
+{
+  "ChallengeName": "PASSWORD_VERIFIER",
+  "ClientId": "clientId123",
+  "ChallengeResponses": {
+    "USERNAME": "username@acme.com",
+    "PASSWORD_CLAIM_SECRET_BLOCK": "passwordClaimSecretBlock456",
+    "PASSWORD_CLAIM_SIGNATURE": "passwordClaimSignature789",
+    "TIMESTAMP": "Thu Apr 6 07:16:19 UTC 2023"
+  }
+}