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