From 84cb3c972781f03db4d9d6118351f4950e8b4955 Mon Sep 17 00:00:00 2001 From: Wouter Born Date: Sat, 15 Apr 2023 22:48:26 +0200 Subject: [PATCH] [windcentrale] Backport fixes for using new API (#14820) * [windcentrale] Adapt binding to new API (#14770) * [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 * [windcentrale] Discover windmills when account goes online (#14807) * Backport to Java 11/openHAB 3.4 Signed-off-by: Wouter Born --- CODEOWNERS | 2 +- .../README.md | 109 ++--- .../WindcentraleBindingConstants.java | 38 +- .../WindcentraleDiscoveryService.java | 169 ++++++++ .../internal/WindcentraleHandlerFactory.java | 21 +- .../internal/api/AuthenticationHelper.java | 395 ++++++++++++++++++ .../internal/api/RequestListener.java | 28 ++ .../internal/api/TokenProvider.java | 156 +++++++ .../internal/api/WindcentraleAPI.java | 148 +++++++ .../internal/config/AccountConfiguration.java | 29 ++ ...Config.java => WindmillConfiguration.java} | 11 +- .../dto/AuthenticationResultResponse.java | 58 +++ .../internal/dto/ChallengeResponse.java | 55 +++ .../internal/dto/CognitoError.java | 32 ++ .../internal/dto/CognitoGson.java | 31 ++ .../internal/dto/InitiateAuthRequest.java | 51 +++ .../internal/dto/KeyResponse.java | 30 ++ .../windcentrale/internal/dto/Project.java | 35 ++ .../dto/RespondToAuthChallengeRequest.java | 38 ++ .../internal/dto/WindcentraleGson.java | 84 ++++ .../windcentrale/internal/dto/Windmill.java | 133 ++++++ .../internal/dto/WindmillStatus.java | 52 +++ .../exception/FailedGettingDataException.java | 38 ++ .../InvalidAccessTokenException.java | 38 ++ .../handler/WindcentraleAccountHandler.java | 182 ++++++++ .../internal/handler/WindcentraleHandler.java | 166 -------- .../handler/WindcentraleWindmillHandler.java | 198 +++++++++ .../listener/ThingStatusListener.java | 28 ++ .../OH-INF/i18n/windcentrale.properties | 52 +-- .../OH-INF/i18n/windcentrale_nl.properties | 52 +-- .../main/resources/OH-INF/thing/account.xml | 22 + .../thing/{millThing.xml => windmill.xml} | 132 +++--- .../internal/dto/CognitoGsonTest.java | 109 +++++ .../windcentrale/internal/dto/DataUtil.java | 74 ++++ .../internal/dto/WindcentraleGsonTest.java | 141 +++++++ .../internal/dto/WindmillTest.java | 70 ++++ ...hentication-result-response-challenge.json | 12 + ...uthentication-result-response-refresh.json | 11 + .../internal/dto/challenge-response-srp.json | 10 + ...nito-error-response-invalid-parameter.json | 4 + ...cognito-error-response-not-authorized.json | 4 + .../dto/initiate-auth-request-refresh.json | 7 + .../dto/initiate-auth-request-srp.json | 8 + .../internal/dto/key-response.json | 14 + .../dto/live-data-response-empty.json | 1 + .../dto/live-data-response-multiple.json | 167 ++++++++ .../dto/live-data-response-single.json | 17 + .../internal/dto/projects-response.json | 77 ++++ .../respond-to-auth-challenge-request.json | 10 + 49 files changed, 2985 insertions(+), 364 deletions(-) create mode 100644 bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/WindcentraleDiscoveryService.java create mode 100644 bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/api/AuthenticationHelper.java create mode 100644 bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/api/RequestListener.java create mode 100644 bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/api/TokenProvider.java create mode 100644 bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/api/WindcentraleAPI.java create mode 100644 bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/config/AccountConfiguration.java rename bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/config/{MillConfig.java => WindmillConfiguration.java} (76%) create mode 100644 bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/AuthenticationResultResponse.java create mode 100644 bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/ChallengeResponse.java create mode 100644 bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/CognitoError.java create mode 100644 bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/CognitoGson.java create mode 100644 bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/InitiateAuthRequest.java create mode 100644 bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/KeyResponse.java create mode 100644 bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/Project.java create mode 100644 bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/RespondToAuthChallengeRequest.java create mode 100644 bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/WindcentraleGson.java create mode 100644 bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/Windmill.java create mode 100644 bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/WindmillStatus.java create mode 100644 bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/exception/FailedGettingDataException.java create mode 100644 bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/exception/InvalidAccessTokenException.java create mode 100644 bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/handler/WindcentraleAccountHandler.java delete mode 100644 bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/handler/WindcentraleHandler.java create mode 100644 bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/handler/WindcentraleWindmillHandler.java create mode 100644 bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/listener/ThingStatusListener.java create mode 100644 bundles/org.openhab.binding.windcentrale/src/main/resources/OH-INF/thing/account.xml rename bundles/org.openhab.binding.windcentrale/src/main/resources/OH-INF/thing/{millThing.xml => windmill.xml} (58%) create mode 100644 bundles/org.openhab.binding.windcentrale/src/test/java/org/openhab/binding/windcentrale/internal/dto/CognitoGsonTest.java create mode 100644 bundles/org.openhab.binding.windcentrale/src/test/java/org/openhab/binding/windcentrale/internal/dto/DataUtil.java create mode 100644 bundles/org.openhab.binding.windcentrale/src/test/java/org/openhab/binding/windcentrale/internal/dto/WindcentraleGsonTest.java create mode 100644 bundles/org.openhab.binding.windcentrale/src/test/java/org/openhab/binding/windcentrale/internal/dto/WindmillTest.java create mode 100644 bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/authentication-result-response-challenge.json create mode 100644 bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/authentication-result-response-refresh.json create mode 100644 bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/challenge-response-srp.json create mode 100644 bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/cognito-error-response-invalid-parameter.json create mode 100644 bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/cognito-error-response-not-authorized.json create mode 100644 bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/initiate-auth-request-refresh.json create mode 100644 bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/initiate-auth-request-srp.json create mode 100644 bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/key-response.json create mode 100644 bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/live-data-response-empty.json create mode 100644 bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/live-data-response-multiple.json create mode 100644 bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/live-data-response-single.json create mode 100644 bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/projects-response.json create mode 100644 bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/respond-to-auth-challenge-request.json diff --git a/CODEOWNERS b/CODEOWNERS index b3fd3206eb0..df68cdfd606 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -367,7 +367,7 @@ /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 diff --git a/bundles/org.openhab.binding.windcentrale/README.md b/bundles/org.openhab.binding.windcentrale/README.md index 0f542e65a86..05d8bb6a742 100644 --- a/bundles/org.openhab.binding.windcentrale/README.md +++ b/bundles/org.openhab.binding.windcentrale/README.md @@ -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 | +### Account -| 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 | +| 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 -``` -Thing windcentrale:mill:geert [ millId=1 ] -Thing windcentrale:mill:reiger [ millId=131, wd=3, refreshInterval=60 ] +```java +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 -``` -Group gReiger "Windcentrale Reiger" +```java +Group gReiger "Windcentrale Reiger" -Number ReigerWindSpeed "Wind speed [%d Bft]" (gReiger) { channel="windcentrale:mill:reiger:windSpeed" } -String ReigerWindDirection "Wind direction [%s]" (gReiger) { channel="windcentrale:mill:reiger:windDirection" } -Number:Power ReigerPowerAbsTot "Total mill power [%.1f %unit%]" (gReiger) { channel="windcentrale:mill:reiger:powerAbsTot" } -Number:Power ReigerPowerAbsWd "Wind shares power [%.1f %unit%]" (gReiger) { channel="windcentrale:mill:reiger:powerAbsWd" } -Number:Dimensionless ReigerPowerRel "Relative power [%.1f %unit%]" (gReiger) { channel="windcentrale:mill:reiger:powerRel" } -Number:Energy ReigerKwh "Current energy [%.0f %unit%]" (gReiger) { channel="windcentrale:mill:reiger:kwh" } -Number:Energy ReigerKwhForecast "Energy forecast [%.0f %unit%]" (gReiger) { channel="windcentrale:mill:reiger:kwhForecast" } -Number:Dimensionless ReigerRunPercentage "Run percentage [%.1f %unit%]" (gReiger) { channel="windcentrale:mill:reiger:runPercentage" } -Number:Time ReigerRunTime "Run time [%.0f %unit%]" (gReiger) { channel="windcentrale:mill:reiger:runTime" } -DateTime ReigerTimestamp "Update timestamp [%1$ta %1$tR]" (gReiger) { channel="windcentrale:mill:reiger:timestamp" } +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" } ``` diff --git a/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/WindcentraleBindingConstants.java b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/WindcentraleBindingConstants.java index 6001d506eac..f9b114be7a2 100644 --- a/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/WindcentraleBindingConstants.java +++ b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/WindcentraleBindingConstants.java @@ -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 SUPPORTED_THING_TYPES_UIDS = Collections.singleton(THING_TYPE_MILL); + public static final Set 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 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_MILL_ID = "millId"; - public static final String PROPERTY_QTY_WINDDELEN = "wd"; + 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 index 00000000000..3c6226a2a83 --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/WindcentraleDiscoveryService.java @@ -0,0 +1,169 @@ +/** + * Copyright (c) 2010-2022 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.binding.windcentrale.internal.listener.ThingStatusListener; +import org.openhab.core.config.discovery.AbstractDiscoveryService; +import org.openhab.core.config.discovery.DiscoveryResultBuilder; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +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, ThingStatusListener { + + 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(); + accountHandler.removeThingStatusListener(this); + } + + @Override + public @Nullable ThingHandler getThingHandler() { + return accountHandler; + } + + @Override + public void setThingHandler(ThingHandler handler) { + if (handler instanceof WindcentraleAccountHandler) { + WindcentraleAccountHandler accountHandler = (WindcentraleAccountHandler) handler; + accountHandler.addThingStatusListener(this); + this.accountHandler = accountHandler; + } + } + + @Override + protected void startScan() { + logger.debug("Discover windmills (manual discovery)"); + cancelDiscoveryJob(); + discoveryJob = scheduler.submit(this::discoverWindmills); + } + + @Override + protected synchronized void stopScan() { + cancelDiscoveryJob(); + super.stopScan(); + } + + @Override + public void thingStatusChanged(Thing thing, ThingStatus status) { + if (ThingStatus.ONLINE.equals(status)) { + logger.debug("Discover windmills (account online)"); + discoverWindmills(); + } + } + + 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 calculateWindmillShares(List projects) { + Map 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() // + ); + } +} diff --git a/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/WindcentraleHandlerFactory.java b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/WindcentraleHandlerFactory.java index 296b1d043d5..f1e3bb00975 100644 --- a/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/WindcentraleHandlerFactory.java +++ b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/WindcentraleHandlerFactory.java @@ -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 index 00000000000..9ab0a895840 --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/api/AuthenticationHelper.java @@ -0,0 +1,395 @@ +/** + * Copyright (c) 2010-2022 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 index 00000000000..1378294d536 --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/api/RequestListener.java @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2010-2022 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 index 00000000000..438be14024a --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/api/TokenProvider.java @@ -0,0 +1,156 @@ +/** + * Copyright (c) 2010-2022 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 index 00000000000..492df00abc7 --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/api/WindcentraleAPI.java @@ -0,0 +1,148 @@ +/** + * Copyright (c) 2010-2022 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 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 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 getLiveData(Set 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 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 index 00000000000..930776c2496 --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/config/AccountConfiguration.java @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2010-2022 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/WindmillConfiguration.java similarity index 76% rename from bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/config/MillConfig.java rename to bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/config/WindmillConfiguration.java index 0a571911934..f3f8733d37a 100644 --- 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/WindmillConfiguration.java @@ -15,17 +15,18 @@ package org.openhab.binding.windcentrale.internal.config; import org.eclipse.jdt.annotation.NonNullByDefault; /** - * The configuration of a Mill thing. + * 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 MillConfig { +public class WindmillConfiguration { /** - * Windmill identifier + * Windmill name */ - public int millId = 1; + public String name = ""; /** * Refresh interval for refreshing the data in seconds @@ -35,5 +36,5 @@ public class MillConfig { /** * Number of wind shares ("Winddelen") */ - public int wd = 1; + 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 index 00000000000..19dcc83455a --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/AuthenticationResultResponse.java @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2010-2022 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 index 00000000000..88547ee6124 --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/ChallengeResponse.java @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2010-2022 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 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 index 00000000000..17db3c6ae47 --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/CognitoError.java @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2010-2022 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 index 00000000000..2b829a8f20a --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/CognitoGson.java @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2010-2022 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 index 00000000000..473faf51a0a --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/InitiateAuthRequest.java @@ -0,0 +1,51 @@ +/** + * Copyright (c) 2010-2022 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 authParameters = new TreeMap<>(); + + InitiateAuthRequest(String authFlow, String clientId, Map 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 index 00000000000..4d021c91cca --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/KeyResponse.java @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2010-2022 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 index 00000000000..ad698e3e83b --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/Project.java @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2010-2022 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 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 index 00000000000..a26e19ca815 --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/RespondToAuthChallengeRequest.java @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2010-2022 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 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 index 00000000000..3c3ba74df25 --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/WindcentraleGson.java @@ -0,0 +1,84 @@ +/** + * Copyright (c) 2010-2022 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>() { + }.getType(); + + public static final Type PROJECTS_RESPONSE_TYPE = new TypeToken>() { + }.getType(); + + private static class WindmillConverter implements JsonSerializer, JsonDeserializer { + @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, JsonDeserializer { + @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 index 00000000000..28b730444be --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/Windmill.java @@ -0,0 +1,133 @@ +/** + * Copyright (c) 2010-2022 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 index 00000000000..b0c22087e70 --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/dto/WindmillStatus.java @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2010-2022 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 index 00000000000..0677324da9c --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/exception/FailedGettingDataException.java @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2010-2022 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 index 00000000000..06e4498c49a --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/exception/InvalidAccessTokenException.java @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2010-2022 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 index 00000000000..182e165a682 --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/handler/WindcentraleAccountHandler.java @@ -0,0 +1,182 @@ +/** + * Copyright (c) 2010-2022 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.CopyOnWriteArrayList; +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.binding.windcentrale.internal.listener.ThingStatusListener; +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 final List thingStatusListeners = new CopyOnWriteArrayList<>(); + + 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; + } + + public void addThingStatusListener(ThingStatusListener listener) { + thingStatusListeners.add(listener); + listener.thingStatusChanged(thing, thing.getStatus()); + } + + @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> getServices() { + return List.of(WindcentraleDiscoveryService.class); + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + } + + public void removeThingStatusListener(ThingStatusListener listener) { + thingStatusListeners.remove(listener); + } + + @Override + protected void updateStatus(ThingStatus status, ThingStatusDetail detail, @Nullable String comment) { + ThingStatus oldStatus = thing.getStatus(); + super.updateStatus(status, detail, comment); + ThingStatus newStatus = thing.getStatus(); + + if (!oldStatus.equals(newStatus)) { + logger.debug("Updating listeners with status {}", status); + for (ThingStatusListener listener : thingStatusListeners) { + listener.thingStatusChanged(thing, status); + } + } + } + + 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 index f5a1f314992..00000000000 --- a/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/handler/WindcentraleHandler.java +++ /dev/null @@ -1,166 +0,0 @@ -/** - * Copyright (c) 2010-2022 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 index 00000000000..16a7ad61f6f --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/handler/WindcentraleWindmillHandler.java @@ -0,0 +1,198 @@ +/** + * Copyright (c) 2010-2022 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 getWindmillProperties(Windmill windmill) { + Map 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); + } + } +} diff --git a/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/listener/ThingStatusListener.java b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/listener/ThingStatusListener.java new file mode 100644 index 00000000000..9e242d6b2f6 --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/main/java/org/openhab/binding/windcentrale/internal/listener/ThingStatusListener.java @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2010-2022 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.listener; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; + +/** + * Interface for listeners of thing status changes. + * + * @author Wouter Born - Initial contribution + */ +@NonNullByDefault +public interface ThingStatusListener { + + public void thingStatusChanged(Thing thing, ThingStatus status); +} diff --git a/bundles/org.openhab.binding.windcentrale/src/main/resources/OH-INF/i18n/windcentrale.properties b/bundles/org.openhab.binding.windcentrale/src/main/resources/OH-INF/i18n/windcentrale.properties index e2b38a1a05a..076fb02c8b2 100644 --- a/bundles/org.openhab.binding.windcentrale/src/main/resources/OH-INF/i18n/windcentrale.properties +++ b/bundles/org.openhab.binding.windcentrale/src/main/resources/OH-INF/i18n/windcentrale.properties @@ -5,41 +5,31 @@ binding.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 diff --git a/bundles/org.openhab.binding.windcentrale/src/main/resources/OH-INF/i18n/windcentrale_nl.properties b/bundles/org.openhab.binding.windcentrale/src/main/resources/OH-INF/i18n/windcentrale_nl.properties index 6eebdff11a2..9a8b67dfab4 100644 --- a/bundles/org.openhab.binding.windcentrale/src/main/resources/OH-INF/i18n/windcentrale_nl.properties +++ b/bundles/org.openhab.binding.windcentrale/src/main/resources/OH-INF/i18n/windcentrale_nl.properties @@ -5,41 +5,31 @@ binding.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 index 00000000000..f8e1180c35a --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/main/resources/OH-INF/thing/account.xml @@ -0,0 +1,22 @@ + + + + + + An account for using the Windcentrale API + + + + + + + password + + + + + + 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/windmill.xml similarity index 58% rename from bundles/org.openhab.binding.windcentrale/src/main/resources/OH-INF/thing/millThing.xml rename to bundles/org.openhab.binding.windcentrale/src/main/resources/OH-INF/thing/windmill.xml index c83b0f06f43..90f410d9f8b 100644 --- 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/windmill.xml @@ -4,19 +4,22 @@ 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"> - + + + + + - - - - - - - - - + + + + + + + + @@ -24,24 +27,27 @@ Windcentrale + projectCode + - + - - - - - - - - - - + + + + + + + + + + + - 131 + De Blauwe Reiger - + Number of wind shares ("Winddelen") 1 @@ -55,63 +61,59 @@ - - Number - - Wind - + + Number:Energy + + Energy produced this year + Energy + - - String - - - - + Number:Dimensionless Energy - + + Number:Power + + Energy + + + + Number:Power + + Energy + + + + Number:Time + + Run time this year + + + Number:Dimensionless Run percentage this year Energy - - Number:Time - - Run time this year - - - - Number:Power - - Energy - - - - Number:Power - - Energy - - - - Number:Energy - - Energy - - - - Number:Energy - - Energy - - DateTime + + String + + + + + Number + + Wind + + + 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 index 00000000000..f3e72a7b6d7 --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/test/java/org/openhab/binding/windcentrale/internal/dto/CognitoGsonTest.java @@ -0,0 +1,109 @@ +/** + * Copyright (c) 2010-2022 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 index 00000000000..d6c84edff10 --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/test/java/org/openhab/binding/windcentrale/internal/dto/DataUtil.java @@ -0,0 +1,74 @@ +/** + * Copyright (c) 2010-2022 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 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 index 00000000000..764609f0642 --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/test/java/org/openhab/binding/windcentrale/internal/dto/WindcentraleGsonTest.java @@ -0,0 +1,141 @@ +/** + * Copyright (c) 2010-2022 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 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 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 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 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 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 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 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 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 index 00000000000..887d29909db --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/test/java/org/openhab/binding/windcentrale/internal/dto/WindmillTest.java @@ -0,0 +1,70 @@ +/** + * Copyright (c) 2010-2022 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 index 00000000000..d3dd4898137 --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/authentication-result-response-challenge.json @@ -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 index 00000000000..13b7992d674 --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/authentication-result-response-refresh.json @@ -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 index 00000000000..db410643c7b --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/challenge-response-srp.json @@ -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 index 00000000000..10d9b77df76 --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/cognito-error-response-invalid-parameter.json @@ -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 index 00000000000..6f0022a5162 --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/cognito-error-response-not-authorized.json @@ -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 index 00000000000..2b69b280312 --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/initiate-auth-request-refresh.json @@ -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 index 00000000000..85c9d2c3d34 --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/initiate-auth-request-srp.json @@ -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 index 00000000000..33baa633478 --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/key-response.json @@ -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 index 00000000000..0967ef424bc --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/live-data-response-empty.json @@ -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 index 00000000000..aafaca9547e --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/live-data-response-multiple.json @@ -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 index 00000000000..e7275d16613 --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/live-data-response-single.json @@ -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 index 00000000000..a52159354fc --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/projects-response.json @@ -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 index 00000000000..0dc9114c276 --- /dev/null +++ b/bundles/org.openhab.binding.windcentrale/src/test/resources/org/openhab/binding/windcentrale/internal/dto/respond-to-auth-challenge-request.json @@ -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" + } +}