From 685fa4bdc30e50e7c832cd0093ae300534dd0925 Mon Sep 17 00:00:00 2001 From: Martin Date: Sun, 2 Jun 2024 12:46:58 +0200 Subject: [PATCH] [salus] Add support for AWS (#16807) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * AWS support Signed-off-by: Martin Grześlowski Signed-off-by: Ciprian Pascu --- bundles/org.openhab.binding.salus/NOTICE | 5 + bundles/org.openhab.binding.salus/README.md | 16 + bundles/org.openhab.binding.salus/pom.xml | 8 + .../binding/salus/internal/SalusApi.java | 60 +++ .../salus/internal/SalusBindingConstants.java | 3 +- .../salus/internal/SalusHandlerFactory.java | 49 +- .../aws/handler/AwsCloudBridgeConfig.java | 120 +++++ .../aws/handler/AwsCloudBridgeHandler.java | 62 +++ .../internal/aws/http/Authentication.java | 26 ++ .../aws/http/AuthenticationHelper.java | 418 ++++++++++++++++++ .../http/AuthenticationResultResponse.java | 54 +++ .../salus/internal/aws/http/AwsSalusApi.java | 188 ++++++++ .../internal/aws/http/ChallengeResponse.java | 54 +++ .../internal/aws/http/CogitoCredentials.java | 26 ++ .../salus/internal/aws/http/CognitoError.java | 32 ++ .../salus/internal/aws/http/CognitoGson.java | 35 ++ .../GetCredentialsForIdentityResponse.java | 100 +++++ .../internal/aws/http/GetIdResponse.java | 38 ++ .../aws/http/InitiateAuthRequest.java | 47 ++ .../aws/http/InstantDeserializer.java | 36 ++ .../http/RespondToAuthChallengeRequest.java | 40 ++ .../cloud/handler/CloudBridgeConfig.java | 39 ++ .../cloud/handler/CloudBridgeHandler.java | 60 +++ .../internal/{ => cloud}/rest/AuthToken.java | 2 +- .../rest/HttpSalusApi.java} | 131 +++--- ...loudDiscovery.java => SalusDiscovery.java} | 28 +- ...eConfig.java => AbstractBridgeConfig.java} | 124 +++--- ...andler.java => AbstractBridgeHandler.java} | 44 +- .../salus/internal/handler/CloudApi.java | 14 +- .../salus/internal/handler/DeviceHandler.java | 25 +- .../salus/internal/handler/It600Handler.java | 67 +-- .../salus/internal/rest/AbstractSalusApi.java | 108 +++++ .../binding/salus/internal/rest/Device.java | 13 +- .../salus/internal/rest/GsonMapper.java | 174 +++++++- .../salus/internal/rest/HttpClient.java | 2 + .../salus/internal/rest/RestClient.java | 1 + .../salus/internal/rest/RetryHttpClient.java | 5 +- .../exceptions/AuthSalusApiException.java | 34 ++ .../HttpSalusApiException.java | 3 +- .../{ => exceptions}/SalusApiException.java | 6 +- .../UnsuportedSalusApiException.java | 30 ++ .../resources/OH-INF/i18n/salus.properties | 44 +- .../OH-INF/thing/aws-salus-bridge.xml | 101 +++++ .../src/main/resources/OH-INF/thing/it600.xml | 1 + .../resources/OH-INF/thing/thing-types.xml | 1 + .../rest/HttpSalusApiTest.java} | 68 +-- ...overyTest.java => SalusDiscoveryTest.java} | 24 +- .../salus/internal/rest/DeviceTest.java | 54 +-- .../salus/internal/rest/GsonMapperTest.java | 8 +- .../internal/rest/RetryHttpClientTest.java | 2 + 50 files changed, 2301 insertions(+), 329 deletions(-) create mode 100644 bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/SalusApi.java create mode 100644 bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/handler/AwsCloudBridgeConfig.java create mode 100644 bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/handler/AwsCloudBridgeHandler.java create mode 100644 bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/Authentication.java create mode 100644 bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/AuthenticationHelper.java create mode 100644 bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/AuthenticationResultResponse.java create mode 100644 bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/AwsSalusApi.java create mode 100644 bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/ChallengeResponse.java create mode 100644 bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/CogitoCredentials.java create mode 100644 bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/CognitoError.java create mode 100644 bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/CognitoGson.java create mode 100644 bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/GetCredentialsForIdentityResponse.java create mode 100644 bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/GetIdResponse.java create mode 100644 bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/InitiateAuthRequest.java create mode 100644 bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/InstantDeserializer.java create mode 100644 bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/RespondToAuthChallengeRequest.java create mode 100644 bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/cloud/handler/CloudBridgeConfig.java create mode 100644 bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/cloud/handler/CloudBridgeHandler.java rename bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/{ => cloud}/rest/AuthToken.java (95%) rename bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/{rest/SalusApi.java => cloud/rest/HttpSalusApi.java} (59%) rename bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/discovery/{CloudDiscovery.java => SalusDiscovery.java} (76%) rename bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/{CloudBridgeConfig.java => AbstractBridgeConfig.java} (54%) rename bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/{CloudBridgeHandler.java => AbstractBridgeHandler.java} (84%) create mode 100644 bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/AbstractSalusApi.java create mode 100644 bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/exceptions/AuthSalusApiException.java rename bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/{ => exceptions}/HttpSalusApiException.java (92%) rename bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/{ => exceptions}/SalusApiException.java (84%) create mode 100644 bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/exceptions/UnsuportedSalusApiException.java create mode 100644 bundles/org.openhab.binding.salus/src/main/resources/OH-INF/thing/aws-salus-bridge.xml rename bundles/org.openhab.binding.salus/src/test/java/org/openhab/binding/salus/internal/{rest/SalusApiTest.java => cloud/rest/HttpSalusApiTest.java} (79%) rename bundles/org.openhab.binding.salus/src/test/java/org/openhab/binding/salus/internal/discovery/{CloudDiscoveryTest.java => SalusDiscoveryTest.java} (83%) diff --git a/bundles/org.openhab.binding.salus/NOTICE b/bundles/org.openhab.binding.salus/NOTICE index 7814d311f20..1982cf70cb3 100644 --- a/bundles/org.openhab.binding.salus/NOTICE +++ b/bundles/org.openhab.binding.salus/NOTICE @@ -28,3 +28,8 @@ checker-qual * License: GNU General Public License (GPL), version 2, with the classpath exception (https://checkerframework.org/manual/#license) * Project: https://checkerframework.org/ * Source: https://github.com/typetools/checker-framework + +aws-crt +* License: Apache License 2.0 +* Project: https://github.com/awslabs/aws-crt-java +* Source: https://github.com/awslabs/aws-crt-java diff --git a/bundles/org.openhab.binding.salus/README.md b/bundles/org.openhab.binding.salus/README.md index 6f5296b1004..bbe5a45e48c 100644 --- a/bundles/org.openhab.binding.salus/README.md +++ b/bundles/org.openhab.binding.salus/README.md @@ -10,6 +10,7 @@ extensive experience, we accurately identify user needs and introduce products t - **`salus-cloud-bridge`**: This bridge connects to Salus Cloud. Multiple bridges are supported for those with multiple accounts. +- **`salus-aws-bridge`**: This bridge connects to AWS Salus Cloud. Multiple bridges are supported for those with multiple accounts. - **`salus-device`**: A generic Salus device that exposes all properties (as channels) from the Cloud without any modifications. - **`salus-it600-device`**: A temperature controller with extended capabilities. @@ -31,6 +32,21 @@ assumed automatically based on the `oem_model`. | refreshInterval | integer (seconds) | Refresh time in seconds | 30 | no | yes | | propertiesRefreshInterval | integer (seconds) | How long device properties should be cached | 5 | no | yes | +### `salus-aws-bridge` Thing Configuration + +| Name | Type | Description | Default | Required | Advanced | +|---------------------------|-------------------|----------------------------------------------|----------------------------|----------|----------| +| username | text | Username/email to log in to Salus Cloud | N/A | yes | no | +| password | text | Password to log in to Salus Cloud | N/A | yes | no | +| url | text | URL to Salus Cloud | https://eu.salusconnect.io | no | yes | +| refreshInterval | integer (seconds) | Refresh time in seconds | 30 | no | yes | +| propertiesRefreshInterval | integer (seconds) | How long device properties should be cached | 5 | no | yes | +| userPoolId | text | | XGRz3CgoY | no | yes | +| clientId | text | The app client ID | 4pk5efh3v84g5dav43imsv4fbj | no | yes | +| region | text | Region with which the SDK should communicate | eu-central-1 | no | yes | +| companyCode | text | | salus-eu | no | yes | +| awsService | text | | a24u3z7zzwrtdl-ats | no | yes | + ### `salus-device` and `salus-it600-device` Thing Configuration | Name | Type | Description | Default | Required | Advanced | diff --git a/bundles/org.openhab.binding.salus/pom.xml b/bundles/org.openhab.binding.salus/pom.xml index e03c5fcde20..2eeb214e9d1 100644 --- a/bundles/org.openhab.binding.salus/pom.xml +++ b/bundles/org.openhab.binding.salus/pom.xml @@ -34,6 +34,14 @@ compile + + + software.amazon.awssdk.crt + aws-crt + 0.29.19 + compile + + ch.qos.logback diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/SalusApi.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/SalusApi.java new file mode 100644 index 00000000000..6bd470b1946 --- /dev/null +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/SalusApi.java @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.salus.internal; + +import java.util.SortedSet; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.salus.internal.rest.Device; +import org.openhab.binding.salus.internal.rest.DeviceProperty; +import org.openhab.binding.salus.internal.rest.exceptions.AuthSalusApiException; +import org.openhab.binding.salus.internal.rest.exceptions.SalusApiException; + +/** + * The SalusApi class is responsible for interacting with a REST API to perform various operations related to the Salus + * system. It handles authentication, token management, and provides methods to retrieve and manipulate device + * information and properties. + * + * @author Martin Grześlowski - Initial contribution + */ +@NonNullByDefault +public interface SalusApi { + /** + * Finds all available devices. + * + * @return A sorted set of Device objects representing the discovered devices. + * @throws SalusApiException if an error occurs during device discovery. + */ + SortedSet findDevices() throws SalusApiException, AuthSalusApiException; + + /** + * Retrieves the properties of a specific device. + * + * @param dsn The Device Serial Number (DSN) identifying the device. + * @return A sorted set of DeviceProperty objects representing the properties of the device. + * @throws SalusApiException if an error occurs while retrieving device properties. + */ + SortedSet> findDeviceProperties(String dsn) throws SalusApiException, AuthSalusApiException; + + /** + * Sets the value for a specific property of a device. + * + * @param dsn The Device Serial Number (DSN) identifying the device. + * @param propertyName The name of the property to set. + * @param value The new value for the property. + * @return An Object representing the result of setting the property value. + * @throws SalusApiException if an error occurs while setting the property value. + */ + Object setValueForProperty(String dsn, String propertyName, Object value) + throws SalusApiException, AuthSalusApiException; +} diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/SalusBindingConstants.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/SalusBindingConstants.java index 1398e070c2b..ecc290d325b 100644 --- a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/SalusBindingConstants.java +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/SalusBindingConstants.java @@ -36,9 +36,10 @@ public class SalusBindingConstants { public static final ThingTypeUID SALUS_DEVICE_TYPE = new ThingTypeUID(BINDING_ID, "salus-device"); public static final ThingTypeUID SALUS_IT600_DEVICE_TYPE = new ThingTypeUID(BINDING_ID, "salus-it600-device"); public static final ThingTypeUID SALUS_SERVER_TYPE = new ThingTypeUID(BINDING_ID, "salus-cloud-bridge"); + public static final ThingTypeUID SALUS_AWS_TYPE = new ThingTypeUID(BINDING_ID, "salus-aws-bridge"); public static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(SALUS_DEVICE_TYPE, - SALUS_IT600_DEVICE_TYPE, SALUS_SERVER_TYPE); + SALUS_IT600_DEVICE_TYPE, SALUS_SERVER_TYPE, SALUS_AWS_TYPE); public static class SalusCloud { public static final String DEFAULT_URL = "https://eu.salusconnect.io"; diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/SalusHandlerFactory.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/SalusHandlerFactory.java index 93bc189ce37..d09e4db07d8 100644 --- a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/SalusHandlerFactory.java +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/SalusHandlerFactory.java @@ -12,17 +12,19 @@ */ package org.openhab.binding.salus.internal; -import static org.openhab.binding.salus.internal.SalusBindingConstants.SALUS_DEVICE_TYPE; -import static org.openhab.binding.salus.internal.SalusBindingConstants.SALUS_IT600_DEVICE_TYPE; -import static org.openhab.binding.salus.internal.SalusBindingConstants.SALUS_SERVER_TYPE; -import static org.openhab.binding.salus.internal.SalusBindingConstants.SUPPORTED_THING_TYPES_UIDS; +import static org.openhab.binding.salus.internal.SalusBindingConstants.*; +import java.util.Collections; +import java.util.HashMap; import java.util.Hashtable; +import java.util.Map; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.salus.internal.discovery.CloudDiscovery; -import org.openhab.binding.salus.internal.handler.CloudBridgeHandler; +import org.openhab.binding.salus.internal.aws.handler.AwsCloudBridgeHandler; +import org.openhab.binding.salus.internal.cloud.handler.CloudBridgeHandler; +import org.openhab.binding.salus.internal.discovery.SalusDiscovery; +import org.openhab.binding.salus.internal.handler.AbstractBridgeHandler; import org.openhab.binding.salus.internal.handler.DeviceHandler; import org.openhab.binding.salus.internal.handler.It600Handler; import org.openhab.core.config.discovery.DiscoveryService; @@ -33,6 +35,7 @@ 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.framework.ServiceRegistration; import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Reference; @@ -51,6 +54,8 @@ public class SalusHandlerFactory extends BaseThingHandlerFactory { private final Logger logger = LoggerFactory.getLogger(SalusHandlerFactory.class); protected final @NonNullByDefault({}) HttpClientFactory httpClientFactory; + private final Map> discoveryServices = Collections + .synchronizedMap(new HashMap<>()); @Activate public SalusHandlerFactory(@Reference HttpClientFactory httpClientFactory) { @@ -75,10 +80,18 @@ public class SalusHandlerFactory extends BaseThingHandlerFactory { if (SALUS_SERVER_TYPE.equals(thingTypeUID)) { return newSalusCloudBridge(thing); } + if (SALUS_AWS_TYPE.equals(thingTypeUID)) { + return newSalusAwsBridge(thing); + } return null; } + @Override + protected void removeHandler(ThingHandler thingHandler) { + unregisterThingDiscovery(thingHandler); + } + private ThingHandler newSalusDevice(Thing thing) { logger.debug("New Salus Device {}", thing.getUID().getId()); return new DeviceHandler(thing); @@ -91,12 +104,28 @@ public class SalusHandlerFactory extends BaseThingHandlerFactory { private ThingHandler newSalusCloudBridge(Thing thing) { var handler = new CloudBridgeHandler((Bridge) thing, httpClientFactory); - var cloudDiscovery = new CloudDiscovery(handler, handler, handler.getThing().getUID()); - registerThingDiscovery(cloudDiscovery); + registerThingDiscovery(handler); return handler; } - private synchronized void registerThingDiscovery(DiscoveryService discoveryService) { - bundleContext.registerService(DiscoveryService.class.getName(), discoveryService, new Hashtable<>()); + private ThingHandler newSalusAwsBridge(Thing thing) { + var handler = new AwsCloudBridgeHandler((Bridge) thing, httpClientFactory); + registerThingDiscovery(handler); + return handler; + } + + private synchronized void registerThingDiscovery(AbstractBridgeHandler handler) { + var discoveryService = new SalusDiscovery(handler, handler.getThing().getUID()); + var serviceRegistration = bundleContext.registerService(DiscoveryService.class.getName(), discoveryService, + new Hashtable<>()); + discoveryServices.put(handler, serviceRegistration); + } + + private synchronized void unregisterThingDiscovery(ThingHandler handler) { + if (!discoveryServices.containsKey(handler)) { + return; + } + var serviceRegistration = discoveryServices.get(handler); + serviceRegistration.unregister(); } } diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/handler/AwsCloudBridgeConfig.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/handler/AwsCloudBridgeConfig.java new file mode 100644 index 00000000000..1586a94bc52 --- /dev/null +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/handler/AwsCloudBridgeConfig.java @@ -0,0 +1,120 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.salus.internal.aws.handler; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.salus.internal.handler.AbstractBridgeConfig; + +/** + * @author Martin Grześlowski - Initial contribution + */ +@NonNullByDefault +public class AwsCloudBridgeConfig extends AbstractBridgeConfig { + private String userPoolId = "eu-central-1_XGRz3CgoY"; + private String identityPoolId = "60912c00-287d-413b-a2c9-ece3ccef9230"; + private String clientId = "4pk5efh3v84g5dav43imsv4fbj"; + private String region = "eu-central-1"; + private String companyCode = "salus-eu"; + private String awsService = "a24u3z7zzwrtdl-ats"; + + public AwsCloudBridgeConfig() { + setUrl("https://service-api.eu.premium.salusconnect.io"); + } + + public AwsCloudBridgeConfig(String username, String password, String url, long refreshInterval, + long propertiesRefreshInterval, int maxHttpRetries, String userPoolId, String identityPoolId, + String clientId, String region, String companyCode, String awsService) { + super(username, password, url, refreshInterval, propertiesRefreshInterval, maxHttpRetries); + this.userPoolId = userPoolId; + this.identityPoolId = identityPoolId; + this.clientId = clientId; + this.region = region; + this.companyCode = companyCode; + this.awsService = awsService; + if (url.isBlank()) { + setUrl("https://service-api.eu.premium.salusconnect.io"); + } + } + + public String getUserPoolId() { + return userPoolId; + } + + public void setUserPoolId(String userPoolId) { + this.userPoolId = userPoolId; + } + + public String getIdentityPoolId() { + return identityPoolId; + } + + public void setIdentityPoolId(String identityPoolId) { + this.identityPoolId = identityPoolId; + } + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public String getRegion() { + return region; + } + + public void setRegion(String region) { + this.region = region; + } + + public String getCompanyCode() { + return companyCode; + } + + public void setCompanyCode(String companyCode) { + this.companyCode = companyCode; + } + + public String getAwsService() { + return awsService; + } + + public void setAwsService(String awsService) { + this.awsService = awsService; + } + + @Override + public boolean isValid() { + return super.isValid() && !userPoolId.isBlank() && !identityPoolId.isBlank() && !clientId.isBlank() + && !region.isBlank() && !companyCode.isBlank() && !awsService.isBlank(); + } + + @Override + public String toString() { + return "AwsCloudBridgeConfig{" + // + "userPoolId='" + userPoolId + '\'' + // + "identityPoolId='" + identityPoolId + '\'' + // + ", clientId='" + clientId + '\'' + // + ", region='" + region + '\'' + // + ", companyCode='" + companyCode + '\'' + // + ", awsService='" + awsService + '\'' + // + ", username='" + username + '\'' + // + ", password=''" + // + ", url='" + url + '\'' + // + ", refreshInterval=" + refreshInterval + // + ", propertiesRefreshInterval=" + propertiesRefreshInterval + // + ", maxHttpRetries=" + maxHttpRetries + // + '}'; + } +} diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/handler/AwsCloudBridgeHandler.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/handler/AwsCloudBridgeHandler.java new file mode 100644 index 00000000000..0c43588aae0 --- /dev/null +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/handler/AwsCloudBridgeHandler.java @@ -0,0 +1,62 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.salus.internal.aws.handler; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.salus.internal.SalusApi; +import org.openhab.binding.salus.internal.aws.http.AwsSalusApi; +import org.openhab.binding.salus.internal.handler.AbstractBridgeHandler; +import org.openhab.binding.salus.internal.rest.GsonMapper; +import org.openhab.binding.salus.internal.rest.RestClient; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.thing.Bridge; + +/** + * @author Martin Grześlowski - Initial contribution + */ +@NonNullByDefault +public final class AwsCloudBridgeHandler extends AbstractBridgeHandler { + private final HttpClientFactory httpClientFactory; + + public AwsCloudBridgeHandler(Bridge bridge, HttpClientFactory httpClientFactory) { + super(bridge, httpClientFactory, AwsCloudBridgeConfig.class); + this.httpClientFactory = httpClientFactory; + } + + @Override + protected SalusApi newSalusApi(AwsCloudBridgeConfig config, RestClient httpClient, GsonMapper gsonMapper) { + return new AwsSalusApi(httpClientFactory, config.getUsername(), config.getPassword().getBytes(UTF_8), + config.getUrl(), httpClient, gsonMapper, config.getUserPoolId(), config.getIdentityPoolId(), + config.getClientId(), config.getRegion(), config.getCompanyCode(), config.getAwsService()); + } + + @Override + public Set it600RequiredChannels() { + return Set.of("ep9:sIT600TH:LocalTemperature_x100", "ep9:sIT600TH:HeatingSetpoint_x100", + "ep9:sIT600TH:HoldType"); + } + + @Override + public boolean isReadOnly() { + return true; + } + + @Override + public String channelPrefix() { + return "ep9"; + } +} diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/Authentication.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/Authentication.java new file mode 100644 index 00000000000..77726b0eefc --- /dev/null +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/Authentication.java @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.salus.internal.aws.http; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * @author Martin Grześlowski - Initial contribution + */ +@NonNullByDefault +record Authentication(String accessToken, int expiresIn, String tokenType, String refreshToken, String idToken) { + @Override + public String toString() { + return "Authentication{" + hashCode() + "}"; + } +} diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/AuthenticationHelper.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/AuthenticationHelper.java new file mode 100644 index 00000000000..44114e25be0 --- /dev/null +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/AuthenticationHelper.java @@ -0,0 +1,418 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.salus.internal.aws.http; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Objects.requireNonNull; +import static org.eclipse.jetty.http.HttpMethod.POST; +import static org.openhab.binding.salus.internal.aws.http.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.Map; +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.salus.internal.rest.exceptions.AuthSalusApiException; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Copied from org.openhab.binding.windcentrale.internal.api.AuthenticationHelper + * + * @author Martin Grześlowski - Initial contribution + */ +@NonNullByDefault +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 COGNITO_IDENTITY_URL_FORMAT = "https://cognito-identity.%s.amazonaws.com/"; + private static final String INITIATE_AUTH_TARGET = "AWSCognitoIdentityProviderService.InitiateAuth"; + private static final String RESPOND_TO_AUTH_TARGET = "AWSCognitoIdentityProviderService.RespondToAuthChallenge"; + private static final String GET_ID = "AWSCognitoIdentityService.GetId"; + private static final String GET_CREDENTIALS_FOR_IDENTITY = "AWSCognitoIdentityService.GetCredentialsForIdentity"; + + /** + * 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; + private final String identityPoolId; + + public AuthenticationHelper(HttpClientFactory httpClientFactory, String userPoolId, String clientId, String region, + String identityPoolId) { + this.httpClient = httpClientFactory.getCommonHttpClient(); + this.userPoolId = userPoolId; + this.clientId = clientId; + this.region = region; + this.identityPoolId = identityPoolId; + } + + /** + * 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 AuthSalusApiException when SRP authentication fails + */ + public AuthenticationResultResponse performSrpAuthentication(String username, String password) + throws AuthSalusApiException { + 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 AuthSalusApiException( + "Unsupported authentication challenge: " + challengeResponse.challengeName); + } + } catch (IllegalStateException | InvalidKeyException | NoSuchAlgorithmException e) { + throw new AuthSalusApiException("SRP Authentication failed", e); + } + } + + public AuthenticationResultResponse performTokenRefresh(String refreshToken) throws AuthSalusApiException { + InitiateAuthRequest initiateAuthRequest = InitiateAuthRequest.refreshTokenAuth(clientId, refreshToken); + try { + return postInitiateAuthRefresh(initiateAuthRequest); + } catch (IllegalStateException e) { + throw new AuthSalusApiException("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 AuthSalusApiException { + String responseContent = postJson(INITIATE_AUTH_TARGET, GSON.toJson(request)); + return requireNonNull(GSON.fromJson(responseContent, ChallengeResponse.class)); + } + + private AuthenticationResultResponse postInitiateAuthRefresh(InitiateAuthRequest request) + throws AuthSalusApiException { + String responseContent = postJson(INITIATE_AUTH_TARGET, GSON.toJson(request)); + return requireNonNull(GSON.fromJson(responseContent, AuthenticationResultResponse.class)); + } + + private AuthenticationResultResponse postRespondToAuthChallenge(RespondToAuthChallengeRequest request) + throws AuthSalusApiException { + String responseContent = postJson(RESPOND_TO_AUTH_TARGET, GSON.toJson(request)); + return requireNonNull(GSON.fromJson(responseContent, AuthenticationResultResponse.class)); + } + + private String postJson(String target, String requestContent) throws AuthSalusApiException { + return postJson(target, requestContent, String.format(COGNITO_URL_FORMAT, region)); + } + + private String postJson(String target, String requestContent, String url) throws AuthSalusApiException { + try { + 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 AuthSalusApiException(message); + } else { + logger.trace("Response: {}", response); + } + return response; + } catch (InterruptedException | TimeoutException | ExecutionException e) { + throw new AuthSalusApiException("Cognito API request failed: " + e.getMessage(), e); + } + } + + public GetIdResponse getId(AuthenticationResultResponse result) throws AuthSalusApiException { + var request = Map.of(// + "IdentityPoolId", "%s:%s".formatted(region, identityPoolId), // + "Logins", Map.of(// + "cognito-idp.%s.amazonaws.com/%s".formatted(region, userPoolId), // + result.getIdToken())// + ); + var json = postJson(GET_ID, GSON.toJson(request), COGNITO_IDENTITY_URL_FORMAT.formatted(region)); + return requireNonNull(GSON.fromJson(json, GetIdResponse.class)); + } + + public GetCredentialsForIdentityResponse getCredentialsForIdentity(AuthenticationResultResponse accessToken, + String identityId) throws AuthSalusApiException { + var request = Map.of(// + "IdentityId", identityId, // + "Logins", Map.of(// + "cognito-idp.%s.amazonaws.com/%s".formatted(region, userPoolId), // + accessToken.getIdToken())// + ); + var json = postJson(GET_CREDENTIALS_FOR_IDENTITY, GSON.toJson(request), + COGNITO_IDENTITY_URL_FORMAT.formatted(region)); + return requireNonNull(GSON.fromJson(json, GetCredentialsForIdentityResponse.class)); + } +} diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/AuthenticationResultResponse.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/AuthenticationResultResponse.java new file mode 100644 index 00000000000..b45f091779f --- /dev/null +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/AuthenticationResultResponse.java @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.salus.internal.aws.http; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Copied from org.openhab.binding.windcentrale.internal.dto.AuthenticationResultResponse + * + * @author Martin Grześlowski - Initial contribution + */ +@NonNullByDefault +class AuthenticationResultResponse { + + private static class AuthenticationResult { + public String accessToken = ""; + public int expiresIn; + public String idToken = ""; + public String refreshToken = ""; + public String tokenType = ""; + } + + private AuthenticationResult authenticationResult = new AuthenticationResult(); + + public String getAccessToken() { + return authenticationResult.accessToken; + } + + public int getExpiresIn() { + return authenticationResult.expiresIn; + } + + public String getIdToken() { + return authenticationResult.idToken; + } + + public String getRefreshToken() { + return authenticationResult.refreshToken; + } + + public String getTokenType() { + return authenticationResult.tokenType; + } +} diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/AwsSalusApi.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/AwsSalusApi.java new file mode 100644 index 00000000000..0223226469b --- /dev/null +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/AwsSalusApi.java @@ -0,0 +1,188 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.salus.internal.aws.http; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.time.ZoneOffset.UTC; +import static java.util.Objects.requireNonNull; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.SortedSet; +import java.util.TreeSet; +import java.util.concurrent.ExecutionException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.salus.internal.rest.AbstractSalusApi; +import org.openhab.binding.salus.internal.rest.Device; +import org.openhab.binding.salus.internal.rest.DeviceProperty; +import org.openhab.binding.salus.internal.rest.GsonMapper; +import org.openhab.binding.salus.internal.rest.RestClient; +import org.openhab.binding.salus.internal.rest.exceptions.AuthSalusApiException; +import org.openhab.binding.salus.internal.rest.exceptions.SalusApiException; +import org.openhab.binding.salus.internal.rest.exceptions.UnsuportedSalusApiException; +import org.openhab.core.io.net.http.HttpClientFactory; + +import software.amazon.awssdk.crt.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.crt.auth.signing.AwsSigner; +import software.amazon.awssdk.crt.auth.signing.AwsSigningConfig; +import software.amazon.awssdk.crt.auth.signing.AwsSigningResult; +import software.amazon.awssdk.crt.http.HttpHeader; +import software.amazon.awssdk.crt.http.HttpRequest; + +/** + * The SalusApi class is responsible for interacting with a REST API to perform various operations related to the Salus + * system. It handles authentication, token management, and provides methods to retrieve and manipulate device + * information and properties. + * + * @author Martin Grześlowski - Initial contribution + */ +@NonNullByDefault +public class AwsSalusApi extends AbstractSalusApi { + private final AuthenticationHelper authenticationHelper; + private final String companyCode; + private final String awsService; + private final String region; + @Nullable + CogitoCredentials cogitoCredentials; + + private AwsSalusApi(String username, byte[] password, String baseUrl, RestClient restClient, GsonMapper mapper, + Clock clock, AuthenticationHelper authenticationHelper, String companyCode, String awsService, + String region) { + super(username, password, baseUrl, restClient, mapper, clock); + this.authenticationHelper = authenticationHelper; + this.companyCode = companyCode; + this.awsService = awsService; + this.region = region; + } + + public AwsSalusApi(HttpClientFactory httpClientFactory, String username, byte[] password, String baseUrl, + RestClient restClient, GsonMapper gsonMapper, String userPoolId, String identityPoolId, String clientId, + String region, String companyCode, String awsService) { + this(username, password, baseUrl, restClient, gsonMapper, Clock.systemDefaultZone(), + new AuthenticationHelper(httpClientFactory, userPoolId, clientId, region, identityPoolId), companyCode, + awsService, region); + } + + @Override + protected void login() throws AuthSalusApiException { + logger.debug("Login with username '{}'", username); + var result = authenticationHelper.performSrpAuthentication(username, new String(password, UTF_8)); + var localAuth = authentication = new Authentication(result.getAccessToken(), result.getExpiresIn(), + result.getTokenType(), result.getRefreshToken(), result.getIdToken()); + var local = LocalDateTime.now(clock).plusSeconds(localAuth.expiresIn()) + // this is to account that there is a delay between server setting `expires_in` + // and client (OpenHAB) receiving it + .minusSeconds(TOKEN_EXPIRE_TIME_ADJUSTMENT_SECONDS); + var localExpireTime = authTokenExpireTime = ZonedDateTime.of(local, UTC); + + var id = authenticationHelper.getId(result); + + var cogito = authenticationHelper.getCredentialsForIdentity(result, id.getIdentityId()); + cogitoCredentials = new CogitoCredentials(// + cogito.getCredentials().getAccessKeyId(), // + cogito.getCredentials().getSecretKey(), // + cogito.getCredentials().getSessionToken()); + + var cogitoExpirationTime = cogito.getCredentials().getExpiration(); + if (cogitoExpirationTime.isBefore(localExpireTime.toInstant())) { + authTokenExpireTime = ZonedDateTime.ofInstant(cogitoExpirationTime, UTC); + } + } + + @Override + protected void cleanAuth() { + super.cleanAuth(); + cogitoCredentials = null; + } + + @Override + public SortedSet findDevices() throws AuthSalusApiException, SalusApiException { + var result = new TreeSet(); + var gateways = findGateways(); + for (var gatewayId : gateways) { + var response = get(url("/api/v1/occupants/slider_details?id=%s&type=gateway".formatted(gatewayId)), + authHeaders()); + if (response == null) { + continue; + } + result.addAll(mapper.parseAwsDevices(response)); + } + return result; + } + + private List findGateways() throws SalusApiException, AuthSalusApiException { + var response = get(url("/api/v1/occupants/slider_list"), authHeaders()); + if (response == null) { + return List.of(); + } + return mapper.parseAwsGatewayIds(response); + } + + private RestClient.Header[] authHeaders() throws AuthSalusApiException { + refreshAccessToken(); + return new RestClient.Header[] { + new RestClient.Header("x-access-token", requireNonNull(authentication).accessToken()), + new RestClient.Header("x-auth-token", requireNonNull(authentication).idToken()), + new RestClient.Header("x-company-code", companyCode) }; + } + + @Override + public SortedSet> findDeviceProperties(String dsn) + throws SalusApiException, AuthSalusApiException { + var path = "https://%s.iot.%s.amazonaws.com/things/%s/shadow".formatted(awsService, region, dsn); + var time = ZonedDateTime.now(clock).withZoneSameInstant(ZoneId.of("UTC")); + var signingResult = buildSigningResult(dsn, time); + var headers = signingResult.getSignedRequest()// + .getHeaders()// + .stream()// + .map(header -> new RestClient.Header(header.getName(), header.getValue()))// + .toList()// + .toArray(new RestClient.Header[0]); + var response = get(path, headers); + if (response == null) { + return new TreeSet<>(); + } + + return new TreeSet<>(mapper.parseAwsDeviceProperties(response)); + } + + private AwsSigningResult buildSigningResult(String dsn, ZonedDateTime time) + throws SalusApiException, AuthSalusApiException { + refreshAccessToken(); + HttpRequest httpRequest = new HttpRequest("GET", "/things/%s/shadow".formatted(dsn), + new HttpHeader[] { new HttpHeader("host", "") }, null); + var localCredentials = requireNonNull(cogitoCredentials); + try (var config = new AwsSigningConfig()) { + config.setRegion(region); + config.setService("iotdevicegateway"); + config.setCredentialsProvider(new StaticCredentialsProvider.StaticCredentialsProviderBuilder() + .withAccessKeyId(localCredentials.accessKeyId().getBytes(UTF_8)) + .withSecretAccessKey(localCredentials.secretKey().getBytes(UTF_8)) + .withSessionToken(localCredentials.sessionToken().getBytes(UTF_8)).build()); + config.setTime(time.toInstant().toEpochMilli()); + return AwsSigner.sign(httpRequest, config).get(); + } catch (ExecutionException | InterruptedException e) { + throw new SalusApiException("Cannot build AWS signature!", e); + } + } + + @Override + public Object setValueForProperty(String dsn, String propertyName, Object value) throws SalusApiException { + throw new UnsuportedSalusApiException("Setting value is not supported for AWS bridge"); + } +} diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/ChallengeResponse.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/ChallengeResponse.java new file mode 100644 index 00000000000..108c4d2edcd --- /dev/null +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/ChallengeResponse.java @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.salus.internal.aws.http; + +import java.util.Map; +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Copied from org.openhab.binding.windcentrale.internal.dto.ChallengeResponse + * + * @author Martin Grześlowski - Initial contribution + */ +@NonNullByDefault +class ChallengeResponse { + + public String challengeName = ""; + public Map challengeParameters = Map.of(); + + private String getChallengeParameter(String key) { + return Objects.requireNonNullElse(challengeParameters.get(key), ""); + } + + public String getSalt() { + return getChallengeParameter("SALT"); + } + + public String getSecretBlock() { + return getChallengeParameter("SECRET_BLOCK"); + } + + public String getSrpB() { + return getChallengeParameter("SRP_B"); + } + + public String getUsername() { + return getChallengeParameter("USERNAME"); + } + + public String getUserIdForSrp() { + return getChallengeParameter("USER_ID_FOR_SRP"); + } +} diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/CogitoCredentials.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/CogitoCredentials.java new file mode 100644 index 00000000000..0a8226c10d9 --- /dev/null +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/CogitoCredentials.java @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.salus.internal.aws.http; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * @author Martin Grześlowski - Initial contribution + */ +@NonNullByDefault +public record CogitoCredentials(String accessKeyId, String secretKey, String sessionToken) { + @Override + public String toString() { + return "CogitoCredentials{" + hashCode() + "}"; + } +} diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/CognitoError.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/CognitoError.java new file mode 100644 index 00000000000..4c502913ce0 --- /dev/null +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/CognitoError.java @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.salus.internal.aws.http; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +import com.google.gson.annotations.SerializedName; + +/** + * Copied from org.openhab.binding.windcentrale.internal.dto.CognitoError + * + * @author Martin Grześlowski - Initial contribution + */ +@NonNullByDefault +class CognitoError { + + @SerializedName("__type") + public String type = ""; + + @SerializedName("message") + public String message = ""; +} diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/CognitoGson.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/CognitoGson.java new file mode 100644 index 00000000000..5d939c17cc7 --- /dev/null +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/CognitoGson.java @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.salus.internal.aws.http; + +import java.time.Instant; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +import com.google.gson.FieldNamingPolicy; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +/** + * Copied from org.openhab.binding.windcentrale.internal.dto.CognitoGson + * + * @author Martin Grześlowski - Initial contribution + */ +@NonNullByDefault +class CognitoGson { + + public static final Gson GSON = new GsonBuilder()// + .setFieldNamingPolicy(FieldNamingPolicy.UPPER_CAMEL_CASE)// + .registerTypeAdapter(Instant.class, new InstantDeserializer())// + .create(); +} diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/GetCredentialsForIdentityResponse.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/GetCredentialsForIdentityResponse.java new file mode 100644 index 00000000000..e4000b6b167 --- /dev/null +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/GetCredentialsForIdentityResponse.java @@ -0,0 +1,100 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.salus.internal.aws.http; + +import java.time.Instant; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * @author Martin Grześlowski - Initial contribution + */ +@NonNullByDefault +class GetCredentialsForIdentityResponse { + private String identityId = ""; + private Credentials credentials = new Credentials(); + + @NonNullByDefault + static class Credentials { + private String accessKeyId = ""; + private String secretKey = ""; + private String sessionToken = ""; + private Instant expiration = Instant.now(); + + public String getAccessKeyId() { + return accessKeyId; + } + + public void setAccessKeyId(String accessKeyId) { + this.accessKeyId = accessKeyId; + } + + public String getSecretKey() { + return secretKey; + } + + public void setSecretKey(String secretKey) { + this.secretKey = secretKey; + } + + public String getSessionToken() { + return sessionToken; + } + + public void setSessionToken(String sessionToken) { + this.sessionToken = sessionToken; + } + + public Instant getExpiration() { + return expiration; + } + + public void setExpiration(Instant expiration) { + this.expiration = expiration; + } + + @Override + public String toString() { + return "Credentials{" + // + "accessKeyId='" + accessKeyId.hashCode() + '\'' + // + ", secretKey='" + secretKey.hashCode() + '\'' + // + ", sessionToken='" + sessionToken.hashCode() + '\'' + // + ", expiration=" + expiration + // + '}'; + } + } + + String getIdentityId() { + return identityId; + } + + void setIdentityId(String identityId) { + this.identityId = identityId; + } + + Credentials getCredentials() { + return credentials; + } + + void setCredentials(Credentials credentials) { + this.credentials = credentials; + } + + @Override + public String toString() { + return "GetCredentialsForIdentityResponse{" + // + "identityId='" + identityId + '\'' + // + ", credentials=" + credentials + // + '}'; + } +} diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/GetIdResponse.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/GetIdResponse.java new file mode 100644 index 00000000000..5f39cd6f687 --- /dev/null +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/GetIdResponse.java @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.salus.internal.aws.http; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * @author Martin Grześlowski - Initial contribution + */ +@NonNullByDefault +class GetIdResponse { + private String identityId = ""; + + public String getIdentityId() { + return identityId; + } + + public void setIdentityId(String identityId) { + this.identityId = identityId; + } + + @Override + public String toString() { + return "GetIdResponse{" + // + "identityId='" + identityId + '\'' + // + '}'; + } +} diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/InitiateAuthRequest.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/InitiateAuthRequest.java new file mode 100644 index 00000000000..1f97de3d42e --- /dev/null +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/InitiateAuthRequest.java @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.salus.internal.aws.http; + +import java.util.Map; +import java.util.TreeMap; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Copied from org.openhab.binding.windcentrale.internal.dto.InitiateAuthRequest + * + * @author Martin Grześlowski - Initial contribution + */ +@NonNullByDefault +class InitiateAuthRequest { + + public String authFlow = ""; + + public String clientId = ""; + + public Map authParameters = new TreeMap<>(); + + InitiateAuthRequest(String authFlow, String clientId, Map authParameters) { + this.authFlow = authFlow; + this.clientId = clientId; + this.authParameters.putAll(authParameters); + } + + public static InitiateAuthRequest userSrpAuth(String clientId, String username, String srpA) { + return new InitiateAuthRequest("USER_SRP_AUTH", clientId, Map.of("USERNAME", username, "SRP_A", srpA)); + } + + public static InitiateAuthRequest refreshTokenAuth(String clientId, String refreshToken) { + return new InitiateAuthRequest("REFRESH_TOKEN_AUTH", clientId, Map.of("REFRESH_TOKEN", refreshToken)); + } +} diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/InstantDeserializer.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/InstantDeserializer.java new file mode 100644 index 00000000000..16835e429f9 --- /dev/null +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/InstantDeserializer.java @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.salus.internal.aws.http; + +import java.lang.reflect.Type; +import java.time.Instant; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; + +/** + * @author Martin Grześlowski - Initial contribution + */ +@NonNullByDefault +class InstantDeserializer implements JsonDeserializer<@org.eclipse.jdt.annotation.Nullable Instant> { + @Override + @org.eclipse.jdt.annotation.Nullable + public Instant deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) + throws JsonParseException { + return Instant.ofEpochSecond(json.getAsLong()); + } +} diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/RespondToAuthChallengeRequest.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/RespondToAuthChallengeRequest.java new file mode 100644 index 00000000000..f739470a9f5 --- /dev/null +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/aws/http/RespondToAuthChallengeRequest.java @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.salus.internal.aws.http; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Copied from org.openhab.binding.windcentrale.internal.dto.RespondToAuthChallengeRequest + * + * @author Martin Grześlowski - Initial contribution + */ +@NonNullByDefault +class RespondToAuthChallengeRequest { + + public String challengeName = "PASSWORD_VERIFIER"; + public String clientId = ""; + public Map challengeResponses = new LinkedHashMap<>(); + + public RespondToAuthChallengeRequest(String clientId, String username, String passwordClaimSecretBlock, + String passwordClaimSignature, String timestamp) { + this.clientId = clientId; + challengeResponses.put("USERNAME", username); + challengeResponses.put("PASSWORD_CLAIM_SECRET_BLOCK", passwordClaimSecretBlock); + challengeResponses.put("PASSWORD_CLAIM_SIGNATURE", passwordClaimSignature); + challengeResponses.put("TIMESTAMP", timestamp); + } +} diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/cloud/handler/CloudBridgeConfig.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/cloud/handler/CloudBridgeConfig.java new file mode 100644 index 00000000000..ebab334d27a --- /dev/null +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/cloud/handler/CloudBridgeConfig.java @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.salus.internal.cloud.handler; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.salus.internal.handler.AbstractBridgeConfig; + +/** + * @author Martin Grześlowski - Initial contribution + */ +@NonNullByDefault +public class CloudBridgeConfig extends AbstractBridgeConfig { + + public CloudBridgeConfig() { + super(); + } + + public CloudBridgeConfig(String username, String password, String url, long refreshInterval, + long propertiesRefreshInterval, int maxHttpRetries) { + super(username, password, url, refreshInterval, propertiesRefreshInterval, maxHttpRetries); + } + + @Override + public String toString() { + return "CloudBridgeConfig{" + "username='" + username + '\'' + ", password=''" + ", url='" + url + '\'' + + ", refreshInterval=" + refreshInterval + ", propertiesRefreshInterval=" + propertiesRefreshInterval + + '}'; + } +} diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/cloud/handler/CloudBridgeHandler.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/cloud/handler/CloudBridgeHandler.java new file mode 100644 index 00000000000..de0d3086438 --- /dev/null +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/cloud/handler/CloudBridgeHandler.java @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.salus.internal.cloud.handler; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.salus.internal.SalusApi; +import org.openhab.binding.salus.internal.cloud.rest.HttpSalusApi; +import org.openhab.binding.salus.internal.handler.AbstractBridgeHandler; +import org.openhab.binding.salus.internal.handler.CloudApi; +import org.openhab.binding.salus.internal.rest.GsonMapper; +import org.openhab.binding.salus.internal.rest.RestClient; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.thing.Bridge; + +/** + * @author Martin Grześlowski - Initial contribution + */ +@NonNullByDefault +public final class CloudBridgeHandler extends AbstractBridgeHandler implements CloudApi { + + public CloudBridgeHandler(Bridge bridge, HttpClientFactory httpClientFactory) { + super(bridge, httpClientFactory, CloudBridgeConfig.class); + } + + @Override + protected SalusApi newSalusApi(CloudBridgeConfig config, RestClient httpClient, GsonMapper gsonMapper) { + return new HttpSalusApi(config.getUsername(), config.getPassword().getBytes(UTF_8), config.getUrl(), httpClient, + gsonMapper); + } + + @Override + public Set it600RequiredChannels() { + return Set.of("ep_9:sIT600TH:LocalTemperature_x100", "ep_9:sIT600TH:HeatingSetpoint_x100", + "ep_9:sIT600TH:SetHeatingSetpoint_x100", "ep_9:sIT600TH:HoldType", "ep_9:sIT600TH:SetHoldType"); + } + + @Override + public boolean isReadOnly() { + return false; + } + + @Override + public String channelPrefix() { + return "ep_9"; + } +} diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/AuthToken.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/cloud/rest/AuthToken.java similarity index 95% rename from bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/AuthToken.java rename to bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/cloud/rest/AuthToken.java index 3246ae1dd6d..6b0895c6774 100644 --- a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/AuthToken.java +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/cloud/rest/AuthToken.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.salus.internal.rest; +package org.openhab.binding.salus.internal.cloud.rest; import java.util.Objects; diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/SalusApi.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/cloud/rest/HttpSalusApi.java similarity index 59% rename from bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/SalusApi.java rename to bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/cloud/rest/HttpSalusApi.java index d3fd2d0e36f..70e616c445a 100644 --- a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/SalusApi.java +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/cloud/rest/HttpSalusApi.java @@ -10,19 +10,27 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.salus.internal.rest; +package org.openhab.binding.salus.internal.cloud.rest; +import static java.time.ZoneOffset.UTC; import static java.util.Objects.requireNonNull; import java.time.Clock; import java.time.LocalDateTime; +import java.time.ZonedDateTime; import java.util.SortedSet; import java.util.TreeSet; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import org.openhab.binding.salus.internal.rest.AbstractSalusApi; +import org.openhab.binding.salus.internal.rest.Device; +import org.openhab.binding.salus.internal.rest.DeviceProperty; +import org.openhab.binding.salus.internal.rest.GsonMapper; +import org.openhab.binding.salus.internal.rest.RestClient; +import org.openhab.binding.salus.internal.rest.exceptions.AuthSalusApiException; +import org.openhab.binding.salus.internal.rest.exceptions.HttpSalusApiException; +import org.openhab.binding.salus.internal.rest.exceptions.SalusApiException; /** * The SalusApi class is responsible for interacting with a REST API to perform various operations related to the Salus @@ -32,47 +40,43 @@ import org.slf4j.LoggerFactory; * @author Martin Grześlowski - Initial contribution */ @NonNullByDefault -public class SalusApi { +public class HttpSalusApi extends AbstractSalusApi { private static final int MAX_RETRIES = 3; private static final long TOKEN_EXPIRE_TIME_ADJUSTMENT_SECONDS = 3; - private final Logger logger; - private final String username; - private final char[] password; - private final String baseUrl; - private final RestClient restClient; - private final GsonMapper mapper; @Nullable private AuthToken authToken; - @Nullable - private LocalDateTime authTokenExpireTime; - private final Clock clock; - public SalusApi(String username, char[] password, String baseUrl, RestClient restClient, GsonMapper mapper, + public HttpSalusApi(String username, byte[] password, String baseUrl, RestClient restClient, GsonMapper mapper, Clock clock) { - this.username = username; - this.password = password; - this.baseUrl = removeTrailingSlash(baseUrl); - this.restClient = restClient; - this.mapper = mapper; - this.clock = clock; - // thanks to this, logger will always inform for which rest client it's doing the job - // it's helpful when more than one SalusApi exists - logger = LoggerFactory.getLogger(SalusApi.class.getName() + "[" + username.replace(".", "_") + "]"); + super(username, password, baseUrl, restClient, mapper, clock); } - public SalusApi(String username, char[] password, String baseUrl, RestClient restClient, GsonMapper mapper) { - this(username, password, baseUrl, restClient, mapper, Clock.systemDefaultZone()); + public HttpSalusApi(String username, byte[] password, String baseUrl, RestClient restClient, GsonMapper mapper) { + super(username, password, baseUrl, restClient, mapper, Clock.systemDefaultZone()); } - private @Nullable String get(String url, RestClient.Header header, int retryAttempt) throws SalusApiException { + @Override + protected @Nullable String get(String url, RestClient.Header... headers) + throws SalusApiException, AuthSalusApiException { + return this.get(url, 1, headers); + } + + @Override + protected @Nullable String post(String url, RestClient.Content content, RestClient.Header... headers) + throws SalusApiException, AuthSalusApiException { + return this.post(url, content, 1, headers); + } + + private @Nullable String get(String url, int retryAttempt, RestClient.Header... headers) + throws SalusApiException, AuthSalusApiException { refreshAccessToken(); try { - return restClient.get(url, authHeader()); + return restClient.get(url, headers); } catch (HttpSalusApiException ex) { if (ex.getCode() == 401) { if (retryAttempt <= MAX_RETRIES) { forceRefreshAccessToken(); - return get(url, header, retryAttempt + 1); + return get(url, retryAttempt + 1, headers); } logger.debug("Could not refresh access token after {} retries", MAX_RETRIES); } @@ -80,16 +84,16 @@ public class SalusApi { } } - private @Nullable String post(String url, RestClient.Content content, RestClient.Header header, int retryAttempt) - throws SalusApiException { + private @Nullable String post(String url, RestClient.Content content, int retryAttempt, + RestClient.Header... headers) throws SalusApiException, AuthSalusApiException { refreshAccessToken(); try { - return restClient.post(url, content, header); + return restClient.post(url, content, headers); } catch (HttpSalusApiException ex) { if (ex.getCode() == 401) { if (retryAttempt <= MAX_RETRIES) { forceRefreshAccessToken(); - return post(url, content, header, retryAttempt + 1); + return post(url, content, retryAttempt + 1, headers); } logger.debug("Could not refresh access token after {} retries", MAX_RETRIES); } @@ -97,18 +101,12 @@ public class SalusApi { } } - private static String removeTrailingSlash(String str) { - if (str.endsWith("/")) { - return str.substring(0, str.length() - 1); - } - return str; + @Override + protected void login() throws SalusApiException { + login(1); } - private void login(String username, char[] password) throws SalusApiException { - login(username, password, 1); - } - - private void login(String username, char[] password, int retryAttempt) throws SalusApiException { + private void login(int retryAttempt) throws SalusApiException { logger.debug("Login with username '{}', retryAttempt={}", username, retryAttempt); authToken = null; authTokenExpireTime = null; @@ -121,16 +119,17 @@ public class SalusApi { throw new HttpSalusApiException(401, "No response token from server"); } var token = authToken = mapper.authToken(response); - authTokenExpireTime = LocalDateTime.now(clock).plusSeconds(token.expiresIn()) + var local = LocalDateTime.now(clock).plusSeconds(token.expiresIn()) // this is to account that there is a delay between server setting `expires_in` // and client (OpenHAB) receiving it .minusSeconds(TOKEN_EXPIRE_TIME_ADJUSTMENT_SECONDS); + authTokenExpireTime = ZonedDateTime.of(local, UTC); logger.debug("Correctly logged in for user {}, role={}, expires at {} ({} secs)", username, token.role(), authTokenExpireTime, token.expiresIn()); } catch (HttpSalusApiException ex) { if (ex.getCode() == 401 || ex.getCode() == 403) { if (retryAttempt < MAX_RETRIES) { - login(username, password, retryAttempt + 1); + login(retryAttempt + 1); } throw ex; } @@ -138,38 +137,16 @@ public class SalusApi { } } - private void forceRefreshAccessToken() throws SalusApiException { + private void forceRefreshAccessToken() throws AuthSalusApiException { logger.debug("Force refresh access token"); - authToken = null; - authTokenExpireTime = null; + cleanAuth(); refreshAccessToken(); } - private void refreshAccessToken() throws SalusApiException { - if (this.authToken == null || isExpiredToken()) { - try { - login(username, password); - } catch (SalusApiException ex) { - logger.warn("Accesstoken could not be acquired, for user '{}', response={}", username, ex.getMessage()); - this.authToken = null; - this.authTokenExpireTime = null; - throw ex; - } - } - } - - private boolean isExpiredToken() { - var expireTime = authTokenExpireTime; - return expireTime == null || LocalDateTime.now(clock).isAfter(expireTime); - } - - private String url(String url) { - return baseUrl + url; - } - - public SortedSet findDevices() throws SalusApiException { + @Override + public SortedSet findDevices() throws SalusApiException, AuthSalusApiException { refreshAccessToken(); - var response = get(url("/apiv1/devices.json"), authHeader(), 1); + var response = get(url("/apiv1/devices.json"), authHeader()); return new TreeSet<>(mapper.parseDevices(requireNonNull(response))); } @@ -177,20 +154,24 @@ public class SalusApi { return new RestClient.Header("Authorization", "auth_token " + requireNonNull(authToken).accessToken()); } - public SortedSet> findDeviceProperties(String dsn) throws SalusApiException { + @Override + public SortedSet> findDeviceProperties(String dsn) + throws SalusApiException, AuthSalusApiException { refreshAccessToken(); - var response = get(url("/apiv1/dsns/" + dsn + "/properties.json"), authHeader(), 1); + var response = get(url("/apiv1/dsns/" + dsn + "/properties.json"), authHeader()); if (response == null) { throw new SalusApiException("No device properties for device %s".formatted(dsn)); } return new TreeSet<>(mapper.parseDeviceProperties(response)); } - public Object setValueForProperty(String dsn, String propertyName, Object value) throws SalusApiException { + @Override + public Object setValueForProperty(String dsn, String propertyName, Object value) + throws SalusApiException, AuthSalusApiException { refreshAccessToken(); var finalUrl = url("/apiv1/dsns/" + dsn + "/properties/" + propertyName + "/datapoints.json"); var json = mapper.datapointParam(value); - var response = post(finalUrl, new RestClient.Content(json), authHeader(), 1); + var response = post(finalUrl, new RestClient.Content(json), 1, authHeader()); var datapointValue = mapper.datapointValue(response); return datapointValue.orElseThrow(() -> new HttpSalusApiException(404, "No datapoint in return")); } diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/discovery/CloudDiscovery.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/discovery/SalusDiscovery.java similarity index 76% rename from bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/discovery/CloudDiscovery.java rename to bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/discovery/SalusDiscovery.java index 824c650bbc8..fcb12ccdd8b 100644 --- a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/discovery/CloudDiscovery.java +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/discovery/SalusDiscovery.java @@ -12,21 +12,17 @@ */ package org.openhab.binding.salus.internal.discovery; -import static org.openhab.binding.salus.internal.SalusBindingConstants.SALUS_DEVICE_TYPE; -import static org.openhab.binding.salus.internal.SalusBindingConstants.SALUS_IT600_DEVICE_TYPE; -import static org.openhab.binding.salus.internal.SalusBindingConstants.SUPPORTED_THING_TYPES_UIDS; -import static org.openhab.binding.salus.internal.SalusBindingConstants.SalusDevice.DSN; -import static org.openhab.binding.salus.internal.SalusBindingConstants.SalusDevice.IT_600; -import static org.openhab.binding.salus.internal.SalusBindingConstants.SalusDevice.OEM_MODEL; +import static org.openhab.binding.salus.internal.SalusBindingConstants.*; +import static org.openhab.binding.salus.internal.SalusBindingConstants.SalusDevice.*; import java.util.Locale; import java.util.Map; import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.binding.salus.internal.handler.CloudApi; -import org.openhab.binding.salus.internal.handler.CloudBridgeHandler; import org.openhab.binding.salus.internal.rest.Device; -import org.openhab.binding.salus.internal.rest.SalusApiException; +import org.openhab.binding.salus.internal.rest.exceptions.AuthSalusApiException; +import org.openhab.binding.salus.internal.rest.exceptions.SalusApiException; import org.openhab.core.config.discovery.AbstractDiscoveryService; import org.openhab.core.config.discovery.DiscoveryResult; import org.openhab.core.config.discovery.DiscoveryResultBuilder; @@ -39,13 +35,12 @@ import org.slf4j.LoggerFactory; * @author Martin Grześlowski - Initial contribution */ @NonNullByDefault -public class CloudDiscovery extends AbstractDiscoveryService { - private final Logger logger = LoggerFactory.getLogger(CloudDiscovery.class); +public class SalusDiscovery extends AbstractDiscoveryService { + private final Logger logger = LoggerFactory.getLogger(SalusDiscovery.class); private final CloudApi cloudApi; private final ThingUID bridgeUid; - public CloudDiscovery(CloudBridgeHandler bridgeHandler, CloudApi cloudApi, ThingUID bridgeUid) - throws IllegalArgumentException { + public SalusDiscovery(CloudApi cloudApi, ThingUID bridgeUid) throws IllegalArgumentException { super(SUPPORTED_THING_TYPES_UIDS, 10, true); this.cloudApi = cloudApi; this.bridgeUid = bridgeUid; @@ -56,8 +51,8 @@ public class CloudDiscovery extends AbstractDiscoveryService { try { var devices = cloudApi.findDevices(); logger.debug("Found {} devices while scanning", devices.size()); - devices.stream().filter(Device::isConnected).forEach(this::addThing); - } catch (SalusApiException e) { + devices.stream().filter(Device::connected).forEach(this::addThing); + } catch (SalusApiException | AuthSalusApiException e) { logger.warn("Error while scanning", e); stopScan(); } @@ -71,6 +66,7 @@ public class CloudDiscovery extends AbstractDiscoveryService { } private static ThingTypeUID findDeviceType(Device device) { + // cloud device var props = device.properties(); if (props.containsKey(OEM_MODEL)) { var model = props.get(OEM_MODEL); @@ -80,6 +76,10 @@ public class CloudDiscovery extends AbstractDiscoveryService { } } } + // aws device + if (device.dsn().contains(IT_600)) { + return SALUS_IT600_DEVICE_TYPE; + } return SALUS_DEVICE_TYPE; } diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/CloudBridgeConfig.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/AbstractBridgeConfig.java similarity index 54% rename from bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/CloudBridgeConfig.java rename to bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/AbstractBridgeConfig.java index 2c6f74d5b17..1979236a5eb 100644 --- a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/CloudBridgeConfig.java +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/AbstractBridgeConfig.java @@ -20,74 +20,24 @@ import org.eclipse.jdt.annotation.NonNullByDefault; * @author Martin Grześlowski - Initial contribution */ @NonNullByDefault -public class CloudBridgeConfig { - private String username = ""; - private String password = ""; - private String url = ""; - private long refreshInterval = 30; - private long propertiesRefreshInterval = 5; - private int maxHttpRetries = 3; +public abstract class AbstractBridgeConfig { + protected String username = ""; + protected String password = ""; + protected String url = ""; + protected long refreshInterval = 30; + protected long propertiesRefreshInterval = 5; + protected int maxHttpRetries = 3; - public CloudBridgeConfig() { + public AbstractBridgeConfig() { } - public CloudBridgeConfig(String username, String password, String url, long refreshInterval, - long propertiesRefreshInterval) { + public AbstractBridgeConfig(String username, String password, String url, long refreshInterval, + long propertiesRefreshInterval, int maxHttpRetries) { this.username = username; this.password = password; this.url = url; this.refreshInterval = refreshInterval; this.propertiesRefreshInterval = propertiesRefreshInterval; - } - - public String getUsername() { - return username; - } - - public void setUsername(String username) { - this.username = username; - } - - public String getPassword() { - return password; - } - - public void setPassword(String password) { - this.password = password; - } - - public String getUrl() { - if (url.isBlank()) { - return DEFAULT_URL; - } - return url; - } - - public void setUrl(String url) { - this.url = url; - } - - public long getRefreshInterval() { - return refreshInterval; - } - - public void setRefreshInterval(long refreshInterval) { - this.refreshInterval = refreshInterval; - } - - public long getPropertiesRefreshInterval() { - return propertiesRefreshInterval; - } - - public void setPropertiesRefreshInterval(long propertiesRefreshInterval) { - this.propertiesRefreshInterval = propertiesRefreshInterval; - } - - public int getMaxHttpRetries() { - return maxHttpRetries; - } - - public void setMaxHttpRetries(int maxHttpRetries) { this.maxHttpRetries = maxHttpRetries; } @@ -95,10 +45,54 @@ public class CloudBridgeConfig { return !username.isBlank() && !password.isBlank(); } - @Override - public String toString() { - return "CloudBridgeConfig{" + "username='" + username + '\'' + ", password=''" + ", url='" + url + '\'' - + ", refreshInterval=" + refreshInterval + ", propertiesRefreshInterval=" + propertiesRefreshInterval - + '}'; + public final String getUsername() { + return username; + } + + public final void setUsername(String username) { + this.username = username; + } + + public final String getPassword() { + return password; + } + + public final void setPassword(String password) { + this.password = password; + } + + public final String getUrl() { + if (url.isBlank()) { + return DEFAULT_URL; + } + return url; + } + + public final void setUrl(String url) { + this.url = url; + } + + public final long getRefreshInterval() { + return refreshInterval; + } + + public final void setRefreshInterval(long refreshInterval) { + this.refreshInterval = refreshInterval; + } + + public final long getPropertiesRefreshInterval() { + return propertiesRefreshInterval; + } + + public final void setPropertiesRefreshInterval(long propertiesRefreshInterval) { + this.propertiesRefreshInterval = propertiesRefreshInterval; + } + + public final int getMaxHttpRetries() { + return maxHttpRetries; + } + + public final void setMaxHttpRetries(int maxHttpRetries) { + this.maxHttpRetries = maxHttpRetries; } } diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/CloudBridgeHandler.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/AbstractBridgeHandler.java similarity index 84% rename from bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/CloudBridgeHandler.java rename to bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/AbstractBridgeHandler.java index 98ee6dbaf53..5520572362e 100644 --- a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/CloudBridgeHandler.java +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/AbstractBridgeHandler.java @@ -22,12 +22,14 @@ import static org.openhab.core.types.RefreshType.REFRESH; import java.time.Duration; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.SortedSet; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.salus.internal.SalusApi; import org.openhab.binding.salus.internal.SalusBindingConstants; import org.openhab.binding.salus.internal.rest.Device; import org.openhab.binding.salus.internal.rest.DeviceProperty; @@ -35,8 +37,8 @@ import org.openhab.binding.salus.internal.rest.GsonMapper; import org.openhab.binding.salus.internal.rest.HttpClient; import org.openhab.binding.salus.internal.rest.RestClient; import org.openhab.binding.salus.internal.rest.RetryHttpClient; -import org.openhab.binding.salus.internal.rest.SalusApi; -import org.openhab.binding.salus.internal.rest.SalusApiException; +import org.openhab.binding.salus.internal.rest.exceptions.AuthSalusApiException; +import org.openhab.binding.salus.internal.rest.exceptions.SalusApiException; import org.openhab.core.common.ThreadPoolManager; import org.openhab.core.io.net.http.HttpClientFactory; import org.openhab.core.thing.Bridge; @@ -55,9 +57,11 @@ import com.github.benmanes.caffeine.cache.LoadingCache; * @author Martin Grześlowski - Initial contribution */ @NonNullByDefault -public final class CloudBridgeHandler extends BaseBridgeHandler implements CloudApi { - private Logger logger = LoggerFactory.getLogger(CloudBridgeHandler.class.getName()); +public abstract class AbstractBridgeHandler extends BaseBridgeHandler + implements CloudApi { + protected Logger logger = LoggerFactory.getLogger(this.getClass()); private final HttpClientFactory httpClientFactory; + private final Class configClass; @NonNullByDefault({}) private LoadingCache>> devicePropertiesCache; @Nullable @@ -65,14 +69,15 @@ public final class CloudBridgeHandler extends BaseBridgeHandler implements Cloud @Nullable private ScheduledFuture scheduledFuture; - public CloudBridgeHandler(Bridge bridge, HttpClientFactory httpClientFactory) { + public AbstractBridgeHandler(Bridge bridge, HttpClientFactory httpClientFactory, Class configClass) { super(bridge); this.httpClientFactory = httpClientFactory; + this.configClass = configClass; } @Override public void initialize() { - CloudBridgeConfig config = this.getConfigAs(CloudBridgeConfig.class); + var config = this.getConfigAs(configClass); if (!config.isValid()) { updateStatus(OFFLINE, CONFIGURATION_ERROR, "@text/cloud-bridge-handler.initialize.username-pass-not-valid"); return; @@ -81,11 +86,9 @@ public final class CloudBridgeHandler extends BaseBridgeHandler implements Cloud if (config.getMaxHttpRetries() > 0) { httpClient = new RetryHttpClient(httpClient, config.getMaxHttpRetries()); } - @Nullable - SalusApi localSalusApi = salusApi = new SalusApi(config.getUsername(), config.getPassword().toCharArray(), - config.getUrl(), httpClient, GsonMapper.INSTANCE); + var localSalusApi = salusApi = newSalusApi(config, httpClient, GsonMapper.INSTANCE); logger = LoggerFactory - .getLogger(CloudBridgeHandler.class.getName() + "[" + config.getUsername().replace(".", "_") + "]"); + .getLogger(this.getClass().getName() + "[" + config.getUsername().replace(".", "_") + "]"); ScheduledExecutorService scheduledPool = ThreadPoolManager.getScheduledPool(SalusBindingConstants.BINDING_ID); scheduledPool.schedule(() -> tryConnectToCloud(localSalusApi), 1, MICROSECONDS); @@ -101,6 +104,8 @@ public final class CloudBridgeHandler extends BaseBridgeHandler implements Cloud // check *tryConnectToCloud(SalusApi)* } + protected abstract SalusApi newSalusApi(ConfigT config, RestClient httpClient, GsonMapper gsonMapper); + private void tryConnectToCloud(SalusApi localSalusApi) { try { localSalusApi.findDevices(); @@ -109,6 +114,9 @@ public final class CloudBridgeHandler extends BaseBridgeHandler implements Cloud } catch (SalusApiException ex) { updateStatus(OFFLINE, COMMUNICATION_ERROR, "@text/cloud-bridge-handler.initialize.cannot-connect-to-cloud [\"" + ex.getMessage() + "\"]"); + } catch (AuthSalusApiException ex) { + updateStatus(OFFLINE, COMMUNICATION_ERROR, + "@text/cloud-bridge-handler.initialize.auth-exception [\"" + ex.getMessage() + "\"]"); } } @@ -158,13 +166,15 @@ public final class CloudBridgeHandler extends BaseBridgeHandler implements Cloud } @Override - public SortedSet> findPropertiesForDevice(String dsn) throws SalusApiException { + public SortedSet> findPropertiesForDevice(String dsn) + throws SalusApiException, AuthSalusApiException { logger.debug("Finding properties for device {} using salusClient", dsn); return requireNonNull(salusApi).findDeviceProperties(dsn); } @Override - public boolean setValueForProperty(String dsn, String propertyName, Object value) throws SalusApiException { + public boolean setValueForProperty(String dsn, String propertyName, Object value) + throws SalusApiException, AuthSalusApiException { try { @Nullable SalusApi api = requireNonNull(salusApi); @@ -205,19 +215,23 @@ public final class CloudBridgeHandler extends BaseBridgeHandler implements Cloud "Cannot set value {} ({}) for property {} ({}) on device {} because value class does not match property class", setValue, setValue.getClass().getSimpleName(), propertyName, prop.getClass().getSimpleName(), dsn); return false; - } catch (SalusApiException ex) { + } catch (AuthSalusApiException | SalusApiException ex) { devicePropertiesCache.invalidateAll(); throw ex; } } @Override - public SortedSet findDevices() throws SalusApiException { + public SortedSet findDevices() throws SalusApiException, AuthSalusApiException { return requireNonNull(this.salusApi).findDevices(); } @Override - public Optional findDevice(String dsn) throws SalusApiException { + public Optional findDevice(String dsn) throws SalusApiException, AuthSalusApiException { return findDevices().stream().filter(device -> device.dsn().equals(dsn)).findFirst(); } + + public abstract Set it600RequiredChannels(); + + public abstract String channelPrefix(); } diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/CloudApi.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/CloudApi.java index 8ef4d784500..7cd4bdc8293 100644 --- a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/CloudApi.java +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/CloudApi.java @@ -18,7 +18,8 @@ import java.util.SortedSet; import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.binding.salus.internal.rest.Device; import org.openhab.binding.salus.internal.rest.DeviceProperty; -import org.openhab.binding.salus.internal.rest.SalusApiException; +import org.openhab.binding.salus.internal.rest.exceptions.AuthSalusApiException; +import org.openhab.binding.salus.internal.rest.exceptions.SalusApiException; /** * @author Martin Grześlowski - Initial contribution @@ -30,7 +31,7 @@ public interface CloudApi { * * @return all devices from cloud */ - SortedSet findDevices() throws SalusApiException; + SortedSet findDevices() throws SalusApiException, AuthSalusApiException; /** * Find a device by DSN @@ -38,7 +39,7 @@ public interface CloudApi { * @param dsn of the device to find * @return a device with given DSN (or empty if no found) */ - Optional findDevice(String dsn) throws SalusApiException; + Optional findDevice(String dsn) throws SalusApiException, AuthSalusApiException; /** * Sets value for a property @@ -48,7 +49,8 @@ public interface CloudApi { * @param value value to set * @return if value was properly set */ - boolean setValueForProperty(String dsn, String propertyName, Object value) throws SalusApiException; + boolean setValueForProperty(String dsn, String propertyName, Object value) + throws SalusApiException, AuthSalusApiException; /** * Finds all properties for a device @@ -56,5 +58,7 @@ public interface CloudApi { * @param dsn of the device * @return all properties of the device */ - SortedSet> findPropertiesForDevice(String dsn) throws SalusApiException; + SortedSet> findPropertiesForDevice(String dsn) throws SalusApiException, AuthSalusApiException; + + boolean isReadOnly(); } diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/DeviceHandler.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/DeviceHandler.java index d54ebcfb873..b2531fcc32a 100644 --- a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/DeviceHandler.java +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/DeviceHandler.java @@ -33,7 +33,8 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.salus.internal.SalusBindingConstants; import org.openhab.binding.salus.internal.rest.DeviceProperty; -import org.openhab.binding.salus.internal.rest.SalusApiException; +import org.openhab.binding.salus.internal.rest.exceptions.AuthSalusApiException; +import org.openhab.binding.salus.internal.rest.exceptions.SalusApiException; import org.openhab.core.library.types.DecimalType; import org.openhab.core.library.types.OnOffType; import org.openhab.core.library.types.OpenClosedType; @@ -79,7 +80,7 @@ public class DeviceHandler extends BaseThingHandler { return; } var bridgeHandler = bridge.getHandler(); - if (!(bridgeHandler instanceof CloudBridgeHandler cloudHandler)) { + if (!(bridgeHandler instanceof AbstractBridgeHandler cloudHandler)) { updateStatus(OFFLINE, BRIDGE_UNINITIALIZED, "@text/device-handler.initialize.errors.bridge-wrong-type"); return; } @@ -100,7 +101,7 @@ public class DeviceHandler extends BaseThingHandler { "@text/device-handler.initialize.errors.dsn-not-found [\"" + dsn + "\"]"); return; } - if (!device.get().isConnected()) { + if (!device.get().connected()) { updateStatus(OFFLINE, COMMUNICATION_ERROR, "@text/device-handler.initialize.errors.dsn-not-connected [\"" + dsn + "\"]"); return; @@ -207,6 +208,9 @@ public class DeviceHandler extends BaseThingHandler { @Override public void handleCommand(ChannelUID channelUID, Command command) { + if (command != REFRESH && cloudApi.isReadOnly()) { + return; + } try { if (command instanceof RefreshType) { handleRefreshCommand(channelUID); @@ -226,13 +230,13 @@ public class DeviceHandler extends BaseThingHandler { logger.warn("Does not know how to handle command `{}` ({}) on channel `{}`!", command, command.getClass().getSimpleName(), channelUID); } - } catch (SalusApiException e) { + } catch (AuthSalusApiException | SalusApiException e) { logger.debug("Error while handling command `{}` on channel `{}`", command, channelUID, e); updateStatus(OFFLINE, COMMUNICATION_ERROR, e.getLocalizedMessage()); } } - private void handleRefreshCommand(ChannelUID channelUID) throws SalusApiException { + private void handleRefreshCommand(ChannelUID channelUID) throws SalusApiException, AuthSalusApiException { var id = channelUID.getId(); String salusId; boolean isX100; @@ -281,11 +285,12 @@ public class DeviceHandler extends BaseThingHandler { updateState(channelUID, state); } - private SortedSet> findDeviceProperties() throws SalusApiException { + private SortedSet> findDeviceProperties() throws SalusApiException, AuthSalusApiException { return this.cloudApi.findPropertiesForDevice(dsn); } - private void handleBoolCommand(ChannelUID channelUID, boolean command) throws SalusApiException { + private void handleBoolCommand(ChannelUID channelUID, boolean command) + throws SalusApiException, AuthSalusApiException { var id = channelUID.getId(); String salusId; if (channelUidMap.containsKey(id)) { @@ -298,7 +303,8 @@ public class DeviceHandler extends BaseThingHandler { handleCommand(channelUID, REFRESH); } - private void handleDecimalCommand(ChannelUID channelUID, @Nullable DecimalType command) throws SalusApiException { + private void handleDecimalCommand(ChannelUID channelUID, @Nullable DecimalType command) + throws SalusApiException, AuthSalusApiException { if (command == null) { return; } @@ -319,7 +325,8 @@ public class DeviceHandler extends BaseThingHandler { handleCommand(channelUID, REFRESH); } - private void handleStringCommand(ChannelUID channelUID, StringType command) throws SalusApiException { + private void handleStringCommand(ChannelUID channelUID, StringType command) + throws SalusApiException, AuthSalusApiException { var id = channelUID.getId(); String salusId; if (channelUidMap.containsKey(id)) { diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/It600Handler.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/It600Handler.java index fbc5231ca1c..d6bb34b3a60 100644 --- a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/It600Handler.java +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/handler/It600Handler.java @@ -14,31 +14,25 @@ package org.openhab.binding.salus.internal.handler; import static java.math.RoundingMode.HALF_EVEN; import static java.util.Objects.requireNonNull; -import static org.openhab.binding.salus.internal.SalusBindingConstants.Channels.It600.EXPECTED_TEMPERATURE; -import static org.openhab.binding.salus.internal.SalusBindingConstants.Channels.It600.TEMPERATURE; -import static org.openhab.binding.salus.internal.SalusBindingConstants.Channels.It600.WORK_TYPE; -import static org.openhab.binding.salus.internal.SalusBindingConstants.It600Device.HoldType.AUTO; -import static org.openhab.binding.salus.internal.SalusBindingConstants.It600Device.HoldType.MANUAL; -import static org.openhab.binding.salus.internal.SalusBindingConstants.It600Device.HoldType.OFF; -import static org.openhab.binding.salus.internal.SalusBindingConstants.It600Device.HoldType.TEMPORARY_MANUAL; +import static org.openhab.binding.salus.internal.SalusBindingConstants.Channels.It600.*; +import static org.openhab.binding.salus.internal.SalusBindingConstants.It600Device.HoldType.*; import static org.openhab.binding.salus.internal.SalusBindingConstants.SalusDevice.DSN; import static org.openhab.core.library.unit.SIUnits.CELSIUS; import static org.openhab.core.thing.ThingStatus.OFFLINE; import static org.openhab.core.thing.ThingStatus.ONLINE; -import static org.openhab.core.thing.ThingStatusDetail.BRIDGE_UNINITIALIZED; -import static org.openhab.core.thing.ThingStatusDetail.COMMUNICATION_ERROR; -import static org.openhab.core.thing.ThingStatusDetail.CONFIGURATION_ERROR; +import static org.openhab.core.thing.ThingStatusDetail.*; +import static org.openhab.core.types.RefreshType.REFRESH; import java.math.BigDecimal; import java.math.MathContext; import java.util.ArrayList; import java.util.Optional; -import java.util.Set; import java.util.SortedSet; import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.binding.salus.internal.rest.DeviceProperty; -import org.openhab.binding.salus.internal.rest.SalusApiException; +import org.openhab.binding.salus.internal.rest.exceptions.AuthSalusApiException; +import org.openhab.binding.salus.internal.rest.exceptions.SalusApiException; import org.openhab.core.library.types.DecimalType; import org.openhab.core.library.types.QuantityType; import org.openhab.core.library.types.StringType; @@ -56,14 +50,12 @@ import org.slf4j.LoggerFactory; @NonNullByDefault public class It600Handler extends BaseThingHandler { private static final BigDecimal ONE_HUNDRED = new BigDecimal(100); - private static final Set REQUIRED_CHANNELS = Set.of("ep_9:sIT600TH:LocalTemperature_x100", - "ep_9:sIT600TH:HeatingSetpoint_x100", "ep_9:sIT600TH:SetHeatingSetpoint_x100", "ep_9:sIT600TH:HoldType", - "ep_9:sIT600TH:SetHoldType"); private final Logger logger; @NonNullByDefault({}) private String dsn; @NonNullByDefault({}) private CloudApi cloudApi; + private String channelPrefix = ""; public It600Handler(Thing thing) { super(thing); @@ -72,17 +64,20 @@ public class It600Handler extends BaseThingHandler { @Override public void initialize() { + AbstractBridgeHandler abstractBridgeHandler; { var bridge = getBridge(); if (bridge == null) { updateStatus(OFFLINE, BRIDGE_UNINITIALIZED, "@text/it600-handler.initialize.errors.no-bridge"); return; } - if (!(bridge.getHandler() instanceof CloudBridgeHandler cloudHandler)) { + if (!(bridge.getHandler() instanceof AbstractBridgeHandler cloudHandler)) { updateStatus(OFFLINE, BRIDGE_UNINITIALIZED, "@text/it600-handler.initialize.errors.bridge-wrong-type"); return; } this.cloudApi = cloudHandler; + abstractBridgeHandler = cloudHandler; + channelPrefix = abstractBridgeHandler.channelPrefix(); } dsn = (String) getConfig().get(DSN); @@ -102,7 +97,7 @@ public class It600Handler extends BaseThingHandler { return; } // device is not connected - if (!device.get().isConnected()) { + if (!device.get().connected()) { updateStatus(OFFLINE, COMMUNICATION_ERROR, "@text/it600-handler.initialize.errors.dsn-not-connected [\"" + dsn + "\"]"); return; @@ -110,7 +105,7 @@ public class It600Handler extends BaseThingHandler { // device is missing properties try { var deviceProperties = findDeviceProperties().stream().map(DeviceProperty::getName).toList(); - var result = new ArrayList<>(REQUIRED_CHANNELS); + var result = new ArrayList<>(abstractBridgeHandler.it600RequiredChannels()); result.removeAll(deviceProperties); if (!result.isEmpty()) { updateStatus(OFFLINE, CONFIGURATION_ERROR, @@ -133,6 +128,9 @@ public class It600Handler extends BaseThingHandler { @Override public void handleCommand(ChannelUID channelUID, Command command) { + if (command != REFRESH && cloudApi.isReadOnly()) { + return; + } try { var id = channelUID.getId(); switch (id) { @@ -148,19 +146,20 @@ public class It600Handler extends BaseThingHandler { default: logger.warn("Unknown channel `{}` for command `{}`", id, command); } - } catch (SalusApiException e) { + } catch (SalusApiException | AuthSalusApiException e) { logger.debug("Error while handling command `{}` on channel `{}`", command, channelUID, e); updateStatus(OFFLINE, COMMUNICATION_ERROR, e.getLocalizedMessage()); } } - private void handleCommandForTemperature(ChannelUID channelUID, Command command) throws SalusApiException { + private void handleCommandForTemperature(ChannelUID channelUID, Command command) + throws SalusApiException, AuthSalusApiException { if (!(command instanceof RefreshType)) { // only refresh commands are supported for temp channel return; } - findLongProperty("ep_9:sIT600TH:LocalTemperature_x100", "LocalTemperature_x100") + findLongProperty(channelPrefix + ":sIT600TH:LocalTemperature_x100", "LocalTemperature_x100") .map(DeviceProperty.LongDeviceProperty::getValue).map(BigDecimal::new) .map(value -> value.divide(ONE_HUNDRED, new MathContext(5, HALF_EVEN))).map(DecimalType::new) .ifPresent(state -> { @@ -169,9 +168,10 @@ public class It600Handler extends BaseThingHandler { }); } - private void handleCommandForExpectedTemperature(ChannelUID channelUID, Command command) throws SalusApiException { + private void handleCommandForExpectedTemperature(ChannelUID channelUID, Command command) + throws SalusApiException, AuthSalusApiException { if (command instanceof RefreshType) { - findLongProperty("ep_9:sIT600TH:HeatingSetpoint_x100", "HeatingSetpoint_x100") + findLongProperty(channelPrefix + ":sIT600TH:HeatingSetpoint_x100", "HeatingSetpoint_x100") .map(DeviceProperty.LongDeviceProperty::getValue).map(BigDecimal::new) .map(value -> value.divide(ONE_HUNDRED, new MathContext(5, HALF_EVEN))).map(DecimalType::new) .ifPresent(state -> { @@ -190,15 +190,17 @@ public class It600Handler extends BaseThingHandler { if (rawValue != null) { var value = rawValue.multiply(ONE_HUNDRED).longValue(); - var property = findLongProperty("ep_9:sIT600TH:SetHeatingSetpoint_x100", "SetHeatingSetpoint_x100"); + var property = findLongProperty(channelPrefix + ":sIT600TH:SetHeatingSetpoint_x100", + "SetHeatingSetpoint_x100"); if (property.isEmpty()) { return; } var wasSet = cloudApi.setValueForProperty(dsn, property.get().getName(), value); if (wasSet) { - findLongProperty("ep_9:sIT600TH:HeatingSetpoint_x100", "HeatingSetpoint_x100") + findLongProperty(channelPrefix + ":sIT600TH:HeatingSetpoint_x100", "HeatingSetpoint_x100") .ifPresent(prop -> prop.setValue(value)); - findLongProperty("ep_9:sIT600TH:HoldType", "HoldType").ifPresent(prop -> prop.setValue((long) MANUAL)); + findLongProperty(channelPrefix + ":sIT600TH:HoldType", "HoldType") + .ifPresent(prop -> prop.setValue((long) MANUAL)); updateStatus(ONLINE); } return; @@ -208,10 +210,11 @@ public class It600Handler extends BaseThingHandler { command.getClass().getSimpleName(), channelUID); } - private void handleCommandForWorkType(ChannelUID channelUID, Command command) throws SalusApiException { + private void handleCommandForWorkType(ChannelUID channelUID, Command command) + throws SalusApiException, AuthSalusApiException { if (command instanceof RefreshType) { - findLongProperty("ep_9:sIT600TH:HoldType", "HoldType").map(DeviceProperty.LongDeviceProperty::getValue) - .map(value -> switch (value.intValue()) { + findLongProperty(channelPrefix + ":sIT600TH:HoldType", "HoldType") + .map(DeviceProperty.LongDeviceProperty::getValue).map(value -> switch (value.intValue()) { case AUTO -> "AUTO"; case MANUAL -> "MANUAL"; case TEMPORARY_MANUAL -> "TEMPORARY_MANUAL"; @@ -241,7 +244,7 @@ public class It600Handler extends BaseThingHandler { logger.warn("Unknown value `{}` for property HoldType!", typedCommand); return; } - var property = findLongProperty("ep_9:sIT600TH:SetHoldType", "SetHoldType"); + var property = findLongProperty(channelPrefix + ":sIT600TH:SetHoldType", "SetHoldType"); if (property.isEmpty()) { return; } @@ -255,7 +258,7 @@ public class It600Handler extends BaseThingHandler { } private Optional findLongProperty(String name, String shortName) - throws SalusApiException { + throws SalusApiException, AuthSalusApiException { var deviceProperties = findDeviceProperties(); var property = deviceProperties.stream().filter(p -> p.getName().equals(name)) .filter(DeviceProperty.LongDeviceProperty.class::isInstance) @@ -271,7 +274,7 @@ public class It600Handler extends BaseThingHandler { return property; } - private SortedSet> findDeviceProperties() throws SalusApiException { + private SortedSet> findDeviceProperties() throws SalusApiException, AuthSalusApiException { return this.cloudApi.findPropertiesForDevice(dsn); } } diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/AbstractSalusApi.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/AbstractSalusApi.java new file mode 100644 index 00000000000..d2e8464f33e --- /dev/null +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/AbstractSalusApi.java @@ -0,0 +1,108 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.salus.internal.rest; + +import java.time.Clock; +import java.time.ZonedDateTime; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.salus.internal.SalusApi; +import org.openhab.binding.salus.internal.rest.exceptions.AuthSalusApiException; +import org.openhab.binding.salus.internal.rest.exceptions.SalusApiException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * @author Martin Grześlowski - Initial contribution + */ +@NonNullByDefault +public abstract class AbstractSalusApi implements SalusApi { + protected static final long TOKEN_EXPIRE_TIME_ADJUSTMENT_SECONDS = 3; + protected final Logger logger; + protected final String username; + protected final byte[] password; + protected final String baseUrl; + protected final RestClient restClient; + protected final GsonMapper mapper; + @Nullable + protected ZonedDateTime authTokenExpireTime; + protected final Clock clock; + @Nullable + protected AuthT authentication; + + protected AbstractSalusApi(String username, byte[] password, String baseUrl, RestClient restClient, + GsonMapper mapper, Clock clock) { + this.username = username; + this.password = password; + this.baseUrl = removeTrailingSlash(baseUrl); + this.restClient = restClient; + this.mapper = mapper; + this.clock = clock; + // thanks to this, logger will always inform for which rest client it's doing the job + // it's helpful when more than one SalusApi exists + logger = LoggerFactory.getLogger(this.getClass().getName() + "[" + username.replace(".", "_") + "]"); + } + + public AbstractSalusApi(String username, byte[] password, String baseUrl, RestClient restClient, + GsonMapper mapper) { + this(username, password, baseUrl, restClient, mapper, Clock.systemDefaultZone()); + } + + protected @Nullable String get(String url, RestClient.Header... headers) + throws SalusApiException, AuthSalusApiException { + refreshAccessToken(); + return restClient.get(url, headers); + } + + protected @Nullable String post(String url, RestClient.Content content, RestClient.Header... headers) + throws SalusApiException, AuthSalusApiException { + refreshAccessToken(); + return restClient.post(url, content, headers); + } + + private static String removeTrailingSlash(String str) { + if (str.endsWith("/")) { + return str.substring(0, str.length() - 1); + } + return str; + } + + protected final synchronized void refreshAccessToken() throws AuthSalusApiException { + if (this.authentication == null || isExpiredToken()) { + cleanAuth(); + try { + login(); + } catch (Exception ex) { + cleanAuth(); + throw new AuthSalusApiException("Could not log in, for user " + username, ex); + } + } + } + + protected void cleanAuth() { + authentication = null; + authTokenExpireTime = null; + } + + protected abstract void login() throws SalusApiException, AuthSalusApiException; + + private boolean isExpiredToken() { + var expireTime = authTokenExpireTime; + return expireTime == null || ZonedDateTime.now(clock).isAfter(expireTime); + } + + protected final String url(String url) { + return baseUrl + url; + } +} diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/Device.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/Device.java index 955eb634e42..78aa0a57f1c 100644 --- a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/Device.java +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/Device.java @@ -23,12 +23,15 @@ import org.eclipse.jdt.annotation.Nullable; /** * @author Martin Grześlowski - Initial contribution */ -public record Device(@NotNull String dsn, @NotNull String name, +public record Device(@NotNull String dsn, @NotNull String name, boolean connected, @NotNull Map<@NotNull String, @Nullable Object> properties) implements Comparable { public Device { requireNonNull(dsn, "DSN is required!"); requireNonNull(name, "name is required!"); requireNonNull(properties, "properties is required!"); + + dsn = dsn.trim(); + name = name.trim(); } @Override @@ -36,14 +39,6 @@ public record Device(@NotNull String dsn, @NotNull String name, return dsn.compareTo(o.dsn); } - public boolean isConnected() { - if (properties.containsKey("connection_status")) { - var connectionStatus = properties.get("connection_status"); - return connectionStatus != null && "online".equalsIgnoreCase(connectionStatus.toString()); - } - return false; - } - @Override public boolean equals(Object o) { if (this == o) { diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/GsonMapper.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/GsonMapper.java index db9083826cf..bc8e950a95c 100644 --- a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/GsonMapper.java +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/GsonMapper.java @@ -18,8 +18,10 @@ import static java.lang.String.format; import static java.util.Collections.unmodifiableSortedMap; import static java.util.Objects.requireNonNull; import static java.util.Optional.empty; +import static java.util.stream.Collectors.toMap; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; @@ -31,9 +33,9 @@ import java.util.stream.Collectors; import javax.validation.constraints.NotNull; -import org.checkerframework.checker.units.qual.K; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.salus.internal.cloud.rest.AuthToken; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -45,7 +47,7 @@ import com.google.gson.reflect.TypeToken; * The GsonMapper class is responsible for mapping JSON data to Java objects using the Gson library. It provides methods * for converting JSON strings to various types of objects, such as authentication tokens, devices, device properties, * and error messages. - * + * * @author Martin Grześlowski - Initial contribution */ @NonNullByDefault @@ -58,7 +60,7 @@ public class GsonMapper { }; private final Gson gson = new Gson(); - public String loginParam(String username, char[] password) { + public String loginParam(String username, byte[] password) { return gson.toJson(Map.of("user", Map.of("email", username, "password", new String(password)))); } @@ -71,6 +73,38 @@ public class GsonMapper { .filter(Optional::isPresent).map(Optional::get).toList(); } + @SuppressWarnings("unchecked") + public List parseAwsDevices(String json) { + var map = tryParseBody(json, MAP_TYPE_REFERENCE, Map.of()); + if (!map.containsKey("data")) { + return List.of(); + } + + var rawData = map.get("data"); + Map data; + if (rawData instanceof Map dataMap) { + data = (Map) dataMap; + } else { + data = tryParseBody(rawData.toString(), MAP_TYPE_REFERENCE, Map.of()); + } + if (!data.containsKey("items")) { + return List.of(); + } + + var rawItems = data.get("items"); + List items; + if (rawItems instanceof List) { + items = (List) rawItems; + } else { + items = tryParseBody(rawItems.toString(), LIST_TYPE_REFERENCE, List.of()); + } + return items.stream()// + .map(this::parseAwsDevice)// + .filter(Optional::isPresent)// + .map(Optional::get)// + .toList(); + } + private Optional parseDevice(Object obj) { if (!(obj instanceof Map firstLevelMap)) { logger.debug("Cannot parse device, because object is not type of map!\n{}", obj); @@ -132,7 +166,38 @@ public class GsonMapper { } properties = Collections.unmodifiableMap(properties); - return Optional.of(new Device(dsn.trim(), name.trim(), properties)); + return Optional.of(new Device(dsn.trim(), name.trim(), isConnected(properties), properties)); + } + + public boolean isConnected(Map<@NotNull String, @Nullable Object> properties) { + if (properties.containsKey("connection_status")) { + var connectionStatus = properties.get("connection_status"); + return connectionStatus != null && "online".equalsIgnoreCase(connectionStatus.toString()); + } + return false; + } + + private Optional parseAwsDevice(Object obj) { + if (!(obj instanceof Map firstLevelMap)) { + logger.debug("Cannot parse device, because object is not type of map!\n{}", obj); + return empty(); + } + + var dsn = firstLevelMap.get("device_code"); + var name = firstLevelMap.get("name"); + if (dsn == null || name == null) { + return empty(); + } + Map<@Nullable String, @Nullable Object> properties = firstLevelMap.entrySet()// + .stream()// + .filter(entry -> !"device_code".equals(entry.getKey()))// + .filter(entry -> !"name".equals(entry.getKey()))// + .filter(entry -> entry.getKey() != null)// + .filter(entry -> entry.getValue() != null)// + .map(entry -> Map.entry(entry.getKey().toString(), entry.getValue()))// + .collect(toMap(Map.Entry::getKey, Map.Entry::getValue, (v1, v2) -> v1, LinkedHashMap::new)); + + return Optional.of(new Device(dsn.toString(), name.toString(), true, properties)); } @SuppressWarnings("SameParameterValue") @@ -161,6 +226,61 @@ public class GsonMapper { return Collections.unmodifiableList(deviceProperties); } + @SuppressWarnings("unchecked") + public List> parseAwsDeviceProperties(String json) { + var obj = tryParseBody(json, MAP_TYPE_REFERENCE, Map.of()); + if (!obj.containsKey("state")) { + return List.of(); + } + + var rawState = obj.get("state"); + Map state; + if (rawState instanceof Map stateMap) { + state = (Map) stateMap; + } else { + state = tryParseBody(rawState.toString(), MAP_TYPE_REFERENCE, Map.of()); + } + if (!state.containsKey("reported")) { + return List.of(); + } + + var rawReported = state.get("reported"); + Map reported; + if (rawReported instanceof Map reportedMap) { + reported = (Map) reportedMap; + } else { + reported = tryParseBody(rawReported.toString(), MAP_TYPE_REFERENCE, Map.of()); + } + if (!reported.containsKey("11")) { + return List.of(); + } + + var rawEleven = reported.get("11"); + Map eleven; + if (rawEleven instanceof Map elevenMap) { + eleven = (Map) elevenMap; + } else { + eleven = tryParseBody(rawEleven.toString(), MAP_TYPE_REFERENCE, Map.of()); + } + if (!eleven.containsKey("properties")) { + return List.of(); + } + + var deviceProperties = new ArrayList>(); + var rawProperties = eleven.get("properties"); + Map properties; + if (rawProperties instanceof Map propertiesMap) { + properties = (Map) propertiesMap; + } else { + properties = tryParseBody(rawProperties.toString(), MAP_TYPE_REFERENCE, Map.of()); + } + for (var entry : properties.entrySet()) { + var deviceProperty = parseAwsDeviceProperty(entry.getKey(), entry.getValue()); + deviceProperties.add(deviceProperty); + } + return Collections.unmodifiableList(deviceProperties); + } + private Optional> parseDeviceProperty(@Nullable Object obj) { if (!(obj instanceof Map firstLevelMap)) { logger.debug("Cannot parse device property, because object is not type of map!\n{}", obj); @@ -228,6 +348,17 @@ public class GsonMapper { displayName, properties)); } + private DeviceProperty parseAwsDeviceProperty(String key, Object value) { + if (value instanceof Boolean booleanValue) { + return new DeviceProperty.BooleanDeviceProperty(key, true, null, null, null, null, booleanValue, null); + } + if (value instanceof Number numberValue) { + return new DeviceProperty.LongDeviceProperty(key, true, null, null, null, null, numberValue.longValue(), + null); + } + return new DeviceProperty.StringDeviceProperty(key, true, null, null, null, null, value.toString(), null); + } + private DeviceProperty buildDeviceProperty(String name, @Nullable String baseType, @Nullable Object value, @Nullable Boolean readOnly, @Nullable String direction, @Nullable String dataUpdatedAt, @Nullable String productName, @Nullable String displayName, @@ -329,6 +460,41 @@ public class GsonMapper { return Optional.ofNullable(datapoint.get("value")); } + public List parseAwsGatewayIds(String json) { + var map = tryParseBody(json, MAP_TYPE_REFERENCE, Map.of()); + if (!map.containsKey("data")) { + return List.of(); + } + + var data = map.get("data"); + List list; + if (data instanceof Collection collection) { + list = new ArrayList<>(collection); + } else { + list = tryParseBody(data.toString(), LIST_TYPE_REFERENCE, List.of()); + } + + return list.stream()// + .map(this::parseAwsGatewayId)// + .filter(Optional::isPresent)// + .map(Optional::get)// + .toList(); + } + + @SuppressWarnings("unchecked") + public Optional parseAwsGatewayId(Object json) { + Map map; + if (json instanceof Map) { + map = (Map) json; + } else { + map = tryParseBody(json.toString(), MAP_TYPE_REFERENCE, Map.of()); + } + if (!map.containsKey("id")) { + return empty(); + } + return Optional.ofNullable(map.get("id")).map(Object::toString); + } + private static record Pair (K key, @Nullable V value) { } } diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/HttpClient.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/HttpClient.java index 3938c5cb0e1..73951746b75 100644 --- a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/HttpClient.java +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/HttpClient.java @@ -23,6 +23,8 @@ import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jetty.client.HttpResponseException; import org.eclipse.jetty.client.api.Request; import org.eclipse.jetty.client.util.StringContentProvider; +import org.openhab.binding.salus.internal.rest.exceptions.HttpSalusApiException; +import org.openhab.binding.salus.internal.rest.exceptions.SalusApiException; /** * @author Martin Grześlowski - Initial contribution diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/RestClient.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/RestClient.java index 8877d843aaf..75c57e73477 100644 --- a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/RestClient.java +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/RestClient.java @@ -16,6 +16,7 @@ import java.util.List; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.salus.internal.rest.exceptions.SalusApiException; /** * @author Martin Grześlowski - Initial contribution diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/RetryHttpClient.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/RetryHttpClient.java index d28fc960fe5..8f0402b8a98 100644 --- a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/RetryHttpClient.java +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/RetryHttpClient.java @@ -14,6 +14,7 @@ package org.openhab.binding.salus.internal.rest; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.salus.internal.rest.exceptions.SalusApiException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -41,7 +42,7 @@ public class RetryHttpClient implements RestClient { return restClient.get(url, headers); } catch (SalusApiException ex) { if (i < maxRetries - 1) { - logger.debug("Error while calling GET {}. Retrying {}/{}...", i + 1, maxRetries, url, ex); + logger.debug("Error while calling GET {}. Retrying {}/{}...", url, i + 1, maxRetries, ex); } else { throw ex; } @@ -57,7 +58,7 @@ public class RetryHttpClient implements RestClient { return restClient.post(url, content, headers); } catch (SalusApiException ex) { if (i < maxRetries - 1) { - logger.debug("Error while calling POST {}. Retrying {}/{}...", i + 1, maxRetries, url, ex); + logger.debug("Error while calling POST {}. Retrying {}/{}...", url, i + 1, maxRetries, ex); } else { throw ex; } diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/exceptions/AuthSalusApiException.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/exceptions/AuthSalusApiException.java new file mode 100644 index 00000000000..b5dd5c91342 --- /dev/null +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/exceptions/AuthSalusApiException.java @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.salus.internal.rest.exceptions; + +import java.io.Serial; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * @author Martin Grześlowski - Initial contribution + */ +@NonNullByDefault +public class AuthSalusApiException extends Exception { + @Serial + private static final long serialVersionUID = 1L; + + public AuthSalusApiException(String msg, Exception e) { + super(msg, e); + } + + public AuthSalusApiException(String msg) { + super(msg); + } +} diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/HttpSalusApiException.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/exceptions/HttpSalusApiException.java similarity index 92% rename from bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/HttpSalusApiException.java rename to bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/exceptions/HttpSalusApiException.java index e3db55d0553..145268aec5d 100644 --- a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/HttpSalusApiException.java +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/exceptions/HttpSalusApiException.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.salus.internal.rest; +package org.openhab.binding.salus.internal.rest.exceptions; import java.io.Serial; @@ -21,7 +21,6 @@ import org.eclipse.jetty.client.HttpResponseException; * @author Martin Grześlowski - Initial contribution */ @NonNullByDefault -@SuppressWarnings("SerializableHasSerializationMethods") public class HttpSalusApiException extends SalusApiException { @Serial private static final long serialVersionUID = 1L; diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/SalusApiException.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/exceptions/SalusApiException.java similarity index 84% rename from bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/SalusApiException.java rename to bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/exceptions/SalusApiException.java index 4183679d2cb..ff630781c05 100644 --- a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/SalusApiException.java +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/exceptions/SalusApiException.java @@ -10,14 +10,16 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.salus.internal.rest; +package org.openhab.binding.salus.internal.rest.exceptions; import java.io.Serial; +import org.eclipse.jdt.annotation.NonNullByDefault; + /** * @author Martin Grześlowski - Initial contribution */ -@SuppressWarnings("SerializableHasSerializationMethods") +@NonNullByDefault public class SalusApiException extends Exception { @Serial private static final long serialVersionUID = 1L; diff --git a/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/exceptions/UnsuportedSalusApiException.java b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/exceptions/UnsuportedSalusApiException.java new file mode 100644 index 00000000000..6021d316535 --- /dev/null +++ b/bundles/org.openhab.binding.salus/src/main/java/org/openhab/binding/salus/internal/rest/exceptions/UnsuportedSalusApiException.java @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.salus.internal.rest.exceptions; + +import java.io.Serial; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * @author Martin Grześlowski - Initial contribution + */ +@NonNullByDefault +public class UnsuportedSalusApiException extends SalusApiException { + @Serial + private static final long serialVersionUID = 1L; + + public UnsuportedSalusApiException(String msg) { + super(msg); + } +} diff --git a/bundles/org.openhab.binding.salus/src/main/resources/OH-INF/i18n/salus.properties b/bundles/org.openhab.binding.salus/src/main/resources/OH-INF/i18n/salus.properties index ed42a3a93e3..a6529442125 100644 --- a/bundles/org.openhab.binding.salus/src/main/resources/OH-INF/i18n/salus.properties +++ b/bundles/org.openhab.binding.salus/src/main/resources/OH-INF/i18n/salus.properties @@ -5,15 +5,38 @@ addon.salus.description = This is the binding for Salus, a renowned manufacturer # thing types +thing-type.salus.salus-aws-bridge.label = AWS Salus Cloud +thing-type.salus.salus-aws-bridge.description = This bridge serves as a critical connection point to the Salus cloud. It's absolutely necessary for the integration of other Salus devices into the ecosystem, as it provides a pathway for them to interact with the Salus cloud. Without this bridge, the devices would be unable to send, receive or exchange data with the cloud platform, hindering functionality and data utilization. thing-type.salus.salus-cloud-bridge.label = Salus Cloud thing-type.salus.salus-cloud-bridge.description = This bridge serves as a critical connection point to the Salus cloud. It's absolutely necessary for the integration of other Salus devices into the ecosystem, as it provides a pathway for them to interact with the Salus cloud. Without this bridge, the devices would be unable to send, receive or exchange data with the cloud platform, hindering functionality and data utilization. -thing-type.salus.salus-device.label = Salus Binding Thing +thing-type.salus.salus-device.label = Salus Device thing-type.salus.salus-device.description = This is a device type that represents a generic 'thing' for the Salus Binding, working in conjunction with the Salus cloud bridge. Channels will be discovered and established at runtime. The 'dsn' (ID in Salus cloud system) is a mandatory configuration parameter. thing-type.salus.salus-it600-device.label = IT600 Salus Thermostat thing-type.salus.salus-it600-device.description = The IT600 Salus Thermostat Device is a component utilized within the Salus IT600 thermostat system. This device communicates with the Salus cloud bridge and offers features including reading the current temperature, setting the desired temperature, and defining the operation type. The operation of this device depends on a unique Data Source Name (DSN) which serves as an identifier in the Salus cloud system. # thing types config +thing-type.config.salus.salus-aws-bridge.awsService.label = AWS Service +thing-type.config.salus.salus-aws-bridge.clientId.label = Client ID +thing-type.config.salus.salus-aws-bridge.clientId.description = The app client ID +thing-type.config.salus.salus-aws-bridge.companyCode.label = Company Code +thing-type.config.salus.salus-aws-bridge.group.aws.label = AWS +thing-type.config.salus.salus-aws-bridge.group.aws.description = AWS Properties +thing-type.config.salus.salus-aws-bridge.maxHttpRetries.label = Max HTTP Retries +thing-type.config.salus.salus-aws-bridge.maxHttpRetries.description = How many times HTTP requests can be retried +thing-type.config.salus.salus-aws-bridge.password.label = Password +thing-type.config.salus.salus-aws-bridge.password.description = The password for your Salus account. This is used in conjunction with the username or email for authentication purposes. +thing-type.config.salus.salus-aws-bridge.propertiesRefreshInterval.label = Device Property Cache Expiration +thing-type.config.salus.salus-aws-bridge.propertiesRefreshInterval.description = The period (in seconds) after which the cached device properties will be discarded and re-fetched fresh from the Salus cloud. +thing-type.config.salus.salus-aws-bridge.refreshInterval.label = Refresh Interval +thing-type.config.salus.salus-aws-bridge.refreshInterval.description = The interval in seconds at which the connection to the Salus cloud should be refreshed to ensure up-to-date data. +thing-type.config.salus.salus-aws-bridge.region.label = Region +thing-type.config.salus.salus-aws-bridge.region.description = Region with which the SDK should communicate +thing-type.config.salus.salus-aws-bridge.url.label = Salus API URL +thing-type.config.salus.salus-aws-bridge.url.description = The base URL for the Salus cloud. Typically, this should remain as the default, unless directed to change by Salus. +thing-type.config.salus.salus-aws-bridge.userPoolId.label = User Pool ID +thing-type.config.salus.salus-aws-bridge.username.label = Username/Email +thing-type.config.salus.salus-aws-bridge.username.description = The username or email associated with your Salus account. This is required for authentication with the Salus cloud. thing-type.config.salus.salus-cloud-bridge.maxHttpRetries.label = Max HTTP Retries thing-type.config.salus.salus-cloud-bridge.maxHttpRetries.description = How many times HTTP requests can be retried thing-type.config.salus.salus-cloud-bridge.password.label = Password @@ -22,7 +45,7 @@ thing-type.config.salus.salus-cloud-bridge.propertiesRefreshInterval.label = Dev thing-type.config.salus.salus-cloud-bridge.propertiesRefreshInterval.description = The period (in seconds) after which the cached device properties will be discarded and re-fetched fresh from the Salus cloud. thing-type.config.salus.salus-cloud-bridge.refreshInterval.label = Refresh Interval thing-type.config.salus.salus-cloud-bridge.refreshInterval.description = The interval in seconds at which the connection to the Salus cloud should be refreshed to ensure up-to-date data. -thing-type.config.salus.salus-cloud-bridge.url.label = Salus base URL +thing-type.config.salus.salus-cloud-bridge.url.label = Salus API URL thing-type.config.salus.salus-cloud-bridge.url.description = The base URL for the Salus cloud. Typically, this should remain as the default, unless directed to change by Salus. thing-type.config.salus.salus-cloud-bridge.username.label = Username/Email thing-type.config.salus.salus-cloud-bridge.username.description = The username or email associated with your Salus account. This is required for authentication with the Salus cloud. @@ -33,17 +56,17 @@ thing-type.config.salus.salus-it600-device.dsn.description = Data Source Name (D # channel types -channel-type.salus.generic-input-bool-channel.label = Generic Bool Input Channel +channel-type.salus.generic-input-bool-channel.label = Generic Bool Input channel-type.salus.generic-input-bool-channel.description = This channel type represents a generic input. The channel is write-only and its state is represented as a boolean. -channel-type.salus.generic-input-channel.label = Generic Input Channel +channel-type.salus.generic-input-channel.label = Generic Input channel-type.salus.generic-input-channel.description = This channel type represents a generic input. The channel is write-only and its state is represented as a string. -channel-type.salus.generic-input-number-channel.label = Generic Number Input Channel +channel-type.salus.generic-input-number-channel.label = Generic Number Input channel-type.salus.generic-input-number-channel.description = This channel type represents a generic input. The channel is write-only and its state is represented as a numeric. -channel-type.salus.generic-output-bool-channel.label = Generic Bool Output Channel +channel-type.salus.generic-output-bool-channel.label = Generic Bool Output channel-type.salus.generic-output-bool-channel.description = This channel type represents a generic output. The channel is read-only and its state is represented as a boolean. -channel-type.salus.generic-output-channel.label = Generic Output Channel +channel-type.salus.generic-output-channel.label = Generic Output channel-type.salus.generic-output-channel.description = This channel type represents a generic output. The channel is read-only and its state is represented as a string. -channel-type.salus.generic-output-number-channel.label = Generic Number Output Channel +channel-type.salus.generic-output-number-channel.label = Generic Number Output channel-type.salus.generic-output-number-channel.description = This channel type represents a generic output. The channel is read-only and its state is represented as a numeric. channel-type.salus.it600-expected-temp-channel.label = Expected Temperature channel-type.salus.it600-expected-temp-channel.description = Sets the desired temperature in room @@ -55,14 +78,15 @@ channel-type.salus.it600-work-type-channel.state.option.OFF = OFF channel-type.salus.it600-work-type-channel.state.option.MANUAL = Manual channel-type.salus.it600-work-type-channel.state.option.AUTO = Automatic channel-type.salus.it600-work-type-channel.state.option.TEMPORARY_MANUAL = Temporary Manual -channel-type.salus.temperature-input-channel.label = Generic Input Temperature Channel +channel-type.salus.temperature-input-channel.label = Generic Input Temperature channel-type.salus.temperature-input-channel.description = This channel type represents a generic input. The channel is write-only and its state is represented as a temperature (numeric). -channel-type.salus.temperature-output-channel.label = Generic Output Temperature Channel +channel-type.salus.temperature-output-channel.label = Generic Output Temperature channel-type.salus.temperature-output-channel.description = This channel type represents a generic output. The channel is read-only and its state is represented as a temperature (numeric). # code i8n cloud-bridge-handler.initialize.username-pass-not-valid = Username or password is missing +cloud-bridge-handler.initialize.auth-exception = Cannot connect to Salus Cloud! Probably username/password mismatch! {0} cloud-bridge-handler.initialize.cannot-connect-to-cloud = Cannot connect to Salus Cloud! Probably username/password mismatch! {0} cloud-bridge-handler.errors.http = There was an error when sending a request to the Salus Cloud. {0} device-handler.initialize.errors.no-bridge = There is no bridge for this thing. Remove it and add it again. diff --git a/bundles/org.openhab.binding.salus/src/main/resources/OH-INF/thing/aws-salus-bridge.xml b/bundles/org.openhab.binding.salus/src/main/resources/OH-INF/thing/aws-salus-bridge.xml new file mode 100644 index 00000000000..3da84165530 --- /dev/null +++ b/bundles/org.openhab.binding.salus/src/main/resources/OH-INF/thing/aws-salus-bridge.xml @@ -0,0 +1,101 @@ + + + + + + + This bridge serves as a critical connection point to the Salus cloud. It's absolutely necessary for the + integration of other Salus devices into the ecosystem, as it provides a pathway for them to interact with the Salus + cloud. Without this bridge, the devices would be unable to send, receive or exchange data with the cloud platform, + hindering functionality and data utilization. + + + username + + + + AWS Properties + + + + The username or email associated with your Salus account. This is required for authentication with the + Salus cloud. + + + + password + The password for your Salus account. This is used in conjunction with the username or email for + authentication purposes. + + + + https://service-api.eu.premium.salusconnect.io + true + url + The base URL for the Salus cloud. Typically, this should remain as the default, unless directed to + change by Salus. + + + + The interval in seconds at which the connection to the Salus cloud should be refreshed to ensure + up-to-date data. + true + 30 + + + + The period (in seconds) after which the cached device properties will be discarded and re-fetched fresh + from the Salus cloud. + true + 5 + + + + How many times HTTP requests can be retried + true + 3 + + + + eu-central-1_XGRz3CgoY + true + + + + 60912c00-287d-413b-a2c9-ece3ccef9230 + true + + + + + The app client ID + + 4pk5efh3v84g5dav43imsv4fbj + true + + + + + Region with which the SDK should communicate + + eu-central-1 + true + + + + salus-eu + true + + + + a24u3z7zzwrtdl-ats + true + + + + + diff --git a/bundles/org.openhab.binding.salus/src/main/resources/OH-INF/thing/it600.xml b/bundles/org.openhab.binding.salus/src/main/resources/OH-INF/thing/it600.xml index f3c3d6ce62c..d5fd688c5b7 100644 --- a/bundles/org.openhab.binding.salus/src/main/resources/OH-INF/thing/it600.xml +++ b/bundles/org.openhab.binding.salus/src/main/resources/OH-INF/thing/it600.xml @@ -7,6 +7,7 @@ + diff --git a/bundles/org.openhab.binding.salus/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.salus/src/main/resources/OH-INF/thing/thing-types.xml index 2c45679cc60..3a677098608 100644 --- a/bundles/org.openhab.binding.salus/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.salus/src/main/resources/OH-INF/thing/thing-types.xml @@ -8,6 +8,7 @@ + diff --git a/bundles/org.openhab.binding.salus/src/test/java/org/openhab/binding/salus/internal/rest/SalusApiTest.java b/bundles/org.openhab.binding.salus/src/test/java/org/openhab/binding/salus/internal/cloud/rest/HttpSalusApiTest.java similarity index 79% rename from bundles/org.openhab.binding.salus/src/test/java/org/openhab/binding/salus/internal/rest/SalusApiTest.java rename to bundles/org.openhab.binding.salus/src/test/java/org/openhab/binding/salus/internal/cloud/rest/HttpSalusApiTest.java index ce1e0186897..63d5e25cab0 100644 --- a/bundles/org.openhab.binding.salus/src/test/java/org/openhab/binding/salus/internal/rest/SalusApiTest.java +++ b/bundles/org.openhab.binding.salus/src/test/java/org/openhab/binding/salus/internal/cloud/rest/HttpSalusApiTest.java @@ -10,8 +10,9 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.binding.salus.internal.rest; +package org.openhab.binding.salus.internal.cloud.rest; +import static java.nio.charset.StandardCharsets.UTF_8; import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -28,13 +29,20 @@ import org.assertj.core.api.ThrowableAssert.ThrowingCallable; import org.eclipse.jdt.annotation.NonNullByDefault; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.openhab.binding.salus.internal.rest.Device; +import org.openhab.binding.salus.internal.rest.DeviceProperty; +import org.openhab.binding.salus.internal.rest.GsonMapper; +import org.openhab.binding.salus.internal.rest.RestClient; +import org.openhab.binding.salus.internal.rest.exceptions.AuthSalusApiException; +import org.openhab.binding.salus.internal.rest.exceptions.HttpSalusApiException; +import org.openhab.binding.salus.internal.rest.exceptions.SalusApiException; /** * @author Martin Grześlowski - Initial contribution */ @SuppressWarnings("DataFlowIssue") @NonNullByDefault -public class SalusApiTest { +public class HttpSalusApiTest { // Find devices returns sorted set of devices @Test @@ -42,7 +50,7 @@ public class SalusApiTest { public void testFindDevicesReturnsSortedSetOfDevices() throws Exception { // Given var username = "correct_username"; - var password = "correct_password".toCharArray(); + var password = "correct_password".getBytes(UTF_8); var baseUrl = "https://example.com"; var restClient = mock(RestClient.class); var mapper = mock(GsonMapper.class); @@ -55,7 +63,7 @@ public class SalusApiTest { var devices = new ArrayList(); when(mapper.parseDevices(anyString())).thenReturn(devices); - var salusApi = new SalusApi(username, password, baseUrl, restClient, mapper, clock); + var salusApi = new HttpSalusApi(username, password, baseUrl, restClient, mapper, clock); setAuthToken(salusApi, restClient, mapper, authToken); // When @@ -71,7 +79,7 @@ public class SalusApiTest { public void testFindDevicePropertiesReturnsSortedSetOfDeviceProperties() throws Exception { // Given var username = "correct_username"; - var password = "correct_password".toCharArray(); + var password = "correct_password".getBytes(UTF_8); var baseUrl = "https://example.com"; var restClient = mock(RestClient.class); var mapper = mock(GsonMapper.class); @@ -84,7 +92,7 @@ public class SalusApiTest { var deviceProperties = new ArrayList>(); when(mapper.parseDeviceProperties(anyString())).thenReturn(deviceProperties); - var salusApi = new SalusApi(username, password, baseUrl, restClient, mapper, clock); + var salusApi = new HttpSalusApi(username, password, baseUrl, restClient, mapper, clock); setAuthToken(salusApi, restClient, mapper, authToken); // When @@ -100,7 +108,7 @@ public class SalusApiTest { public void testSetValueForPropertyReturnsOkResponseWithDatapointValue() throws Exception { // Given var username = "correct_username"; - var password = "correct_password".toCharArray(); + var password = "correct_password".getBytes(UTF_8); var baseUrl = "https://example.com"; var restClient = mock(RestClient.class); var mapper = mock(GsonMapper.class); @@ -113,7 +121,7 @@ public class SalusApiTest { var datapointValue = new Object(); when(mapper.datapointValue(anyString())).thenReturn(Optional.of(datapointValue)); - var salusApi = new SalusApi(username, password, baseUrl, restClient, mapper, clock); + var salusApi = new HttpSalusApi(username, password, baseUrl, restClient, mapper, clock); setAuthToken(salusApi, restClient, mapper, authToken); // When @@ -129,7 +137,7 @@ public class SalusApiTest { public void testLoginWithIncorrectCredentialsThrowsHttpUnauthorizedException() throws Exception { // Given var username = "incorrect_username"; - var password = "incorrect_password".toCharArray(); + var password = "incorrect_password".getBytes(UTF_8); var baseUrl = "https://example.com"; var restClient = mock(RestClient.class); var mapper = mock(GsonMapper.class); @@ -138,14 +146,14 @@ public class SalusApiTest { when(restClient.post(anyString(), any(), any())) .thenThrow(new HttpSalusApiException(401, "unauthorized_error_json")); - var salusApi = new SalusApi(username, password, baseUrl, restClient, mapper, clock); + var salusApi = new HttpSalusApi(username, password, baseUrl, restClient, mapper, clock); // When ThrowingCallable findDevicesResponse = salusApi::findDevices; // Then - assertThatThrownBy(findDevicesResponse).isInstanceOf(HttpSalusApiException.class) - .hasMessage("HTTP Error 401: unauthorized_error_json"); + assertThatThrownBy(findDevicesResponse).isInstanceOf(AuthSalusApiException.class) + .hasMessage("Could not log in, for user incorrect_username"); } // Find devices with invalid auth token throws HttpUnauthorizedException @@ -154,7 +162,7 @@ public class SalusApiTest { public void testFindDevicesWithInvalidAuthTokenThrowsHttpUnauthorizedException() throws Exception { // Given var username = "correct_username"; - var password = "correct_password".toCharArray(); + var password = "correct_password".getBytes(UTF_8); var baseUrl = "https://example.com"; var restClient = mock(RestClient.class); var mapper = mock(GsonMapper.class); @@ -163,7 +171,7 @@ public class SalusApiTest { var authToken = new AuthToken("access_token", "refresh_token", 3600L, "role"); when(restClient.get(anyString(), any())).thenThrow(new HttpSalusApiException(401, "unauthorized_error_json")); - var salusApi = new SalusApi(username, password, baseUrl, restClient, mapper, clock); + var salusApi = new HttpSalusApi(username, password, baseUrl, restClient, mapper, clock); setAuthToken(salusApi, restClient, mapper, authToken); // When @@ -180,7 +188,7 @@ public class SalusApiTest { public void testFindDevicePropertiesWithInvalidAuthTokenThrowsHttpUnauthorizedException() throws Exception { // Given var username = "correct_username"; - var password = "correct_password".toCharArray(); + var password = "correct_password".getBytes(UTF_8); var baseUrl = "https://example.com"; var restClient = mock(RestClient.class); var mapper = mock(GsonMapper.class); @@ -189,7 +197,7 @@ public class SalusApiTest { var authToken = new AuthToken("access_token", "refresh_token", 3600L, "role"); when(restClient.get(anyString(), any())).thenThrow(new HttpSalusApiException(401, "unauthorized_error_json")); - var salusApi = new SalusApi(username, password, baseUrl, restClient, mapper, clock); + var salusApi = new HttpSalusApi(username, password, baseUrl, restClient, mapper, clock); setAuthToken(salusApi, restClient, mapper, authToken); // When @@ -206,25 +214,21 @@ public class SalusApiTest { public void testSetValueForPropertyWithInvalidAuthTokenThrowsHttpUnauthorizedException() throws Exception { // Given var username = "correct_username"; - var password = "correct_password".toCharArray(); + var password = "correct_password".getBytes(UTF_8); var baseUrl = "https://example.com"; var restClient = mock(RestClient.class); var mapper = mock(GsonMapper.class); var clock = Clock.systemDefaultZone(); - var authToken = new AuthToken("access_token", "refresh_token", 3600L, "role"); - when(restClient.post(anyString(), any(), any())) - .thenThrow(new HttpSalusApiException(401, "unauthorized_error_json")); - - var salusApi = new SalusApi(username, password, baseUrl, restClient, mapper, clock); + var salusApi = new HttpSalusApi(username, password, baseUrl, restClient, mapper, clock); // When ThrowingCallable objectApiResponse = () -> salusApi.setValueForProperty("dsn", "property_name", "value"); // given - assertThatThrownBy(objectApiResponse).isInstanceOf(HttpSalusApiException.class) - .hasMessage("HTTP Error 401: unauthorized_error_json"); + assertThatThrownBy(objectApiResponse).isInstanceOf(AuthSalusApiException.class) + .hasMessage("Could not log in, for user correct_username"); } // Find device properties with invalid DSN returns ApiResponse with error @@ -233,7 +237,7 @@ public class SalusApiTest { public void testFindDevicePropertiesWithInvalidDsnReturnsApiResponseWithError() throws Exception { // Given var username = "correct_username"; - var password = "correct_password".toCharArray(); + var password = "correct_password".getBytes(UTF_8); var baseUrl = "https://example.com"; var restClient = mock(RestClient.class); var mapper = mock(GsonMapper.class); @@ -242,7 +246,7 @@ public class SalusApiTest { var authToken = new AuthToken("access_token", "refresh_token", 3600L, "role"); when(restClient.get(anyString(), any())).thenThrow(new HttpSalusApiException(404, "not found")); - var salusApi = new SalusApi(username, password, baseUrl, restClient, mapper, clock); + var salusApi = new HttpSalusApi(username, password, baseUrl, restClient, mapper, clock); setAuthToken(salusApi, restClient, mapper, authToken); // When @@ -258,7 +262,7 @@ public class SalusApiTest { public void testLoginWithIncorrectCredentials3TimesThrowsHttpForbiddenException() throws Exception { // Given var username = "incorrect_username"; - var password = "incorrect_password".toCharArray(); + var password = "incorrect_password".getBytes(UTF_8); var baseUrl = "https://example.com"; var restClient = mock(RestClient.class); var mapper = mock(GsonMapper.class); @@ -267,20 +271,20 @@ public class SalusApiTest { when(restClient.post(anyString(), any(), any())) .thenThrow(new HttpSalusApiException(403, "forbidden_error_json")); - var salusApi = new SalusApi(username, password, baseUrl, restClient, mapper, clock); + var salusApi = new HttpSalusApi(username, password, baseUrl, restClient, mapper, clock); // When ThrowingCallable findDevicesResponse = salusApi::findDevices; // Then - assertThatThrownBy(findDevicesResponse).isInstanceOf(HttpSalusApiException.class) - .hasMessage("HTTP Error 403: forbidden_error_json"); + assertThatThrownBy(findDevicesResponse).isInstanceOf(AuthSalusApiException.class) + .hasMessage("Could not log in, for user incorrect_username"); } - private void setAuthToken(SalusApi salusApi, RestClient restClient, GsonMapper mapper, AuthToken authToken) + private void setAuthToken(HttpSalusApi salusApi, RestClient restClient, GsonMapper mapper, AuthToken authToken) throws SalusApiException { var username = "correct_username"; - var password = "correct_password".toCharArray(); + var password = "correct_password".getBytes(UTF_8); var inputBody = "login_param_json"; when(mapper.loginParam(username, password)).thenReturn(inputBody); var authTokenJson = "auth_token"; diff --git a/bundles/org.openhab.binding.salus/src/test/java/org/openhab/binding/salus/internal/discovery/CloudDiscoveryTest.java b/bundles/org.openhab.binding.salus/src/test/java/org/openhab/binding/salus/internal/discovery/SalusDiscoveryTest.java similarity index 83% rename from bundles/org.openhab.binding.salus/src/test/java/org/openhab/binding/salus/internal/discovery/CloudDiscoveryTest.java rename to bundles/org.openhab.binding.salus/src/test/java/org/openhab/binding/salus/internal/discovery/SalusDiscoveryTest.java index b0295b48176..f4c93d46ea3 100644 --- a/bundles/org.openhab.binding.salus/src/test/java/org/openhab/binding/salus/internal/discovery/CloudDiscoveryTest.java +++ b/bundles/org.openhab.binding.salus/src/test/java/org/openhab/binding/salus/internal/discovery/SalusDiscoveryTest.java @@ -24,28 +24,31 @@ import java.util.List; import java.util.Random; import java.util.TreeSet; +import javax.validation.constraints.NotNull; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.openhab.binding.salus.internal.handler.CloudApi; -import org.openhab.binding.salus.internal.handler.CloudBridgeHandler; import org.openhab.binding.salus.internal.rest.Device; -import org.openhab.binding.salus.internal.rest.SalusApiException; +import org.openhab.binding.salus.internal.rest.exceptions.SalusApiException; import org.openhab.core.config.discovery.DiscoveryListener; import org.openhab.core.thing.ThingUID; /** * @author Martin Grześlowski - Initial contribution */ -public class CloudDiscoveryTest { +@NonNullByDefault +public class SalusDiscoveryTest { @Test @DisplayName("Method filters out disconnected devices and adds connected devices as things using addThing method") - void testFiltersOutDisconnectedDevicesAndAddsConnectedDevicesAsThings() throws SalusApiException { + void testFiltersOutDisconnectedDevicesAndAddsConnectedDevicesAsThings() throws Exception { // Given var cloudApi = mock(CloudApi.class); - var bridgeHandler = mock(CloudBridgeHandler.class); var bridgeUid = new ThingUID("salus", "salus-device", "boo"); - var discoveryService = new CloudDiscovery(bridgeHandler, cloudApi, bridgeUid); + var discoveryService = new SalusDiscovery(cloudApi, bridgeUid); var discoveryListener = mock(DiscoveryListener.class); discoveryService.addDiscoveryListener(discoveryListener); var device1 = randomDevice(true); @@ -73,12 +76,11 @@ public class CloudDiscoveryTest { @Test @DisplayName("Cloud API throws an exception during device retrieval, method logs the error") - void testLogsErrorWhenCloudApiThrowsException() throws SalusApiException { + void testLogsErrorWhenCloudApiThrowsException() throws Exception { // Given var cloudApi = mock(CloudApi.class); - var bridgeHandler = mock(CloudBridgeHandler.class); var bridgeUid = mock(ThingUID.class); - var discoveryService = new CloudDiscovery(bridgeHandler, cloudApi, bridgeUid); + var discoveryService = new SalusDiscovery(cloudApi, bridgeUid); given(cloudApi.findDevices()).willThrow(new SalusApiException("API error")); @@ -91,10 +93,10 @@ public class CloudDiscoveryTest { private Device randomDevice(boolean connected) { var random = new Random(); - var map = new HashMap(); + var map = new HashMap<@NotNull String, @Nullable Object>(); if (connected) { map.put("connection_status", "online"); } - return new Device("dsn-" + random.nextInt(), "name-" + random.nextInt(), map); + return new Device("dsn-" + random.nextInt(), "name-" + random.nextInt(), connected, map); } } diff --git a/bundles/org.openhab.binding.salus/src/test/java/org/openhab/binding/salus/internal/rest/DeviceTest.java b/bundles/org.openhab.binding.salus/src/test/java/org/openhab/binding/salus/internal/rest/DeviceTest.java index b9574b72e53..ff600e108bc 100644 --- a/bundles/org.openhab.binding.salus/src/test/java/org/openhab/binding/salus/internal/rest/DeviceTest.java +++ b/bundles/org.openhab.binding.salus/src/test/java/org/openhab/binding/salus/internal/rest/DeviceTest.java @@ -36,10 +36,10 @@ class DeviceTest { // Given var properties = new HashMap(); properties.put("connection_status", "online"); - var device = new Device("dsn", "name", properties); + var device = new Device("dsn", "name", true, properties); // When - var result = device.isConnected(); + var result = device.connected(); // Then assertThat(result).isTrue(); @@ -52,10 +52,10 @@ class DeviceTest { // Given var properties = new HashMap(); properties.put("connection_status", "offline"); - var device = new Device("dsn", "name", properties); + var device = new Device("dsn", "name", false, properties); // When - var result = device.isConnected(); + var result = device.connected(); // Then assertThat(result).isFalse(); @@ -67,10 +67,10 @@ class DeviceTest { public void testReturnsFalseIfConnectionStatusPropertyDoesNotExist() { // Given var properties = new HashMap(); - var device = new Device("dsn", "name", properties); + var device = new Device("dsn", "name", false, properties); // When - var result = device.isConnected(); + var result = device.connected(); // Then assertThat(result).isFalse(); @@ -82,10 +82,10 @@ class DeviceTest { public void testReturnsFalseIfPropertiesParameterDoesNotContainConnectionStatusKey() { // Given var properties = new HashMap(); - var device = new Device("dsn", "name", properties); + var device = new Device("dsn", "name", false, properties); // When - var result = device.isConnected(); + var result = device.connected(); // Then assertThat(result).isFalse(); @@ -98,10 +98,10 @@ class DeviceTest { // Given var properties = new HashMap(); properties.put("connection_status", null); - var device = new Device("dsn", "name", properties); + var device = new Device("dsn", "name", false, properties); // When - var result = device.isConnected(); + var result = device.connected(); // Then assertThat(result).isFalse(); @@ -114,10 +114,10 @@ class DeviceTest { // Given var properties = new HashMap(); properties.put("connection_status", 123); - var device = new Device("dsn", "name", properties); + var device = new Device("dsn", "name", false, properties); // When - var result = device.isConnected(); + var result = device.connected(); // Then assertThat(result).isFalse(); @@ -133,7 +133,7 @@ class DeviceTest { Map properties = Map.of("connection_status", "online"); // When - Device device = new Device(dsn, name, properties); + Device device = new Device(dsn, name, true, properties); // Then assertThat(device).isNotNull(); @@ -152,8 +152,8 @@ class DeviceTest { String name2 = "Device 2"; Map properties = Map.of("connection_status", "online"); - Device device1 = new Device(dsn, name1, properties); - Device device2 = new Device(dsn, name2, properties); + Device device1 = new Device(dsn, name1, true, properties); + Device device2 = new Device(dsn, name2, true, properties); // When boolean isEqual = device1.equals(device2); @@ -172,8 +172,8 @@ class DeviceTest { String name = "Device"; Map properties = Map.of("connection_status", "online"); - Device device1 = new Device(dsn1, name, properties); - Device device2 = new Device(dsn2, name, properties); + Device device1 = new Device(dsn1, name, true, properties); + Device device2 = new Device(dsn2, name, true, properties); // When int result1 = device1.compareTo(device2); @@ -186,26 +186,26 @@ class DeviceTest { assertThat(result3).isZero(); } - // The isConnected method should return true if the connection_status property is "online". + // The connected method should return true if the connection_status property is "online". @Test - @DisplayName("The isConnected method should return true if the connection_status property is \"online\"") - public void testIsConnectedMethodShouldReturnTrueIfConnectionStatusIsOnline() { + @DisplayName("The connected method should return true if the connection_status property is \"online\"") + public void testconnectedMethodShouldReturnTrueIfConnectionStatusIsOnline() { // Given String dsn = "123456"; String name = "Device"; Map properties1 = Map.of("connection_status", "online"); Map properties2 = Map.of("connection_status", "offline"); - Device device1 = new Device(dsn, name, properties1); - Device device2 = new Device(dsn, name, properties2); + Device device1 = new Device(dsn, name, true, properties1); + Device device2 = new Device(dsn, name, false, properties2); // When - boolean isConnected1 = device1.isConnected(); - boolean isConnected2 = device2.isConnected(); + boolean connected1 = device1.connected(); + boolean connected2 = device2.connected(); // Then - assertThat(isConnected1).isTrue(); - assertThat(isConnected2).isFalse(); + assertThat(connected1).isTrue(); + assertThat(connected2).isFalse(); } // The toString method should return a string representation of the Device object with its DSN and name. @@ -217,7 +217,7 @@ class DeviceTest { String name = "Device"; Map properties = Map.of("connection_status", "online"); - Device device = new Device(dsn, name, properties); + Device device = new Device(dsn, name, true, properties); // When String result = device.toString(); diff --git a/bundles/org.openhab.binding.salus/src/test/java/org/openhab/binding/salus/internal/rest/GsonMapperTest.java b/bundles/org.openhab.binding.salus/src/test/java/org/openhab/binding/salus/internal/rest/GsonMapperTest.java index bf3d88a40a1..d9c3a1eb7e8 100644 --- a/bundles/org.openhab.binding.salus/src/test/java/org/openhab/binding/salus/internal/rest/GsonMapperTest.java +++ b/bundles/org.openhab.binding.salus/src/test/java/org/openhab/binding/salus/internal/rest/GsonMapperTest.java @@ -12,6 +12,7 @@ */ package org.openhab.binding.salus.internal.rest; +import static java.nio.charset.StandardCharsets.UTF_8; import static org.assertj.core.api.Assertions.assertThat; import java.util.Collections; @@ -20,6 +21,7 @@ import java.util.Optional; import org.eclipse.jdt.annotation.NonNullByDefault; import org.junit.jupiter.api.Test; +import org.openhab.binding.salus.internal.cloud.rest.AuthToken; /** * @author Martin Grześlowski - Initial contribution @@ -33,7 +35,7 @@ public class GsonMapperTest { // Given GsonMapper gsonMapper = GsonMapper.INSTANCE; String username = "test@example.com"; - char[] password = "password".toCharArray(); + byte[] password = "password".getBytes(UTF_8); String expectedJson1 = "{\"user\":{\"email\":\"test@example.com\",\"password\":\"password\"}}"; String expectedJson2 = "{\"user\":{\"password\":\"password\",\"email\":\"test@example.com\"}}"; @@ -65,8 +67,8 @@ public class GsonMapperTest { // Given GsonMapper gsonMapper = GsonMapper.INSTANCE; String json = "[{\"device\":{\"dsn\":\"123\",\"product_name\":\"Product 1\"}},{\"device\":{\"dsn\":\"456\",\"product_name\":\"Product 2\"}}]"; - List expectedDevices = List.of(new Device("123", "Product 1", Collections.emptyMap()), - new Device("456", "Product 2", Collections.emptyMap())); + List expectedDevices = List.of(new Device("123", "Product 1", true, Collections.emptyMap()), + new Device("456", "Product 2", true, Collections.emptyMap())); // When List devices = gsonMapper.parseDevices(json); diff --git a/bundles/org.openhab.binding.salus/src/test/java/org/openhab/binding/salus/internal/rest/RetryHttpClientTest.java b/bundles/org.openhab.binding.salus/src/test/java/org/openhab/binding/salus/internal/rest/RetryHttpClientTest.java index 5506f424b45..561a59079a0 100644 --- a/bundles/org.openhab.binding.salus/src/test/java/org/openhab/binding/salus/internal/rest/RetryHttpClientTest.java +++ b/bundles/org.openhab.binding.salus/src/test/java/org/openhab/binding/salus/internal/rest/RetryHttpClientTest.java @@ -29,6 +29,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.openhab.binding.salus.internal.rest.RestClient.Content; import org.openhab.binding.salus.internal.rest.RestClient.Header; +import org.openhab.binding.salus.internal.rest.exceptions.HttpSalusApiException; +import org.openhab.binding.salus.internal.rest.exceptions.SalusApiException; /** * @author Martin Grześlowski - Initial contribution