* 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
- **`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.
| 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 |
<scope>compile</scope>
</dependency>
<!-- END caffeine -->
+ <!-- START AWS -->
+ <dependency>
+ <groupId>software.amazon.awssdk.crt</groupId>
+ <artifactId>aws-crt</artifactId>
+ <version>0.29.19</version>
+ <scope>compile</scope>
+ </dependency>
+ <!-- END AWS -->
<dependency>
<groupId>ch.qos.logback</groupId>
--- /dev/null
+/**
+ * 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<Device> 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<DeviceProperty<?>> 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;
+}
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<ThingTypeUID> 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";
*/
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;
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;
private final Logger logger = LoggerFactory.getLogger(SalusHandlerFactory.class);
protected final @NonNullByDefault({}) HttpClientFactory httpClientFactory;
+ private final Map<ThingHandler, ServiceRegistration<?>> discoveryServices = Collections
+ .synchronizedMap(new HashMap<>());
@Activate
public SalusHandlerFactory(@Reference HttpClientFactory httpClientFactory) {
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);
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 ThingHandler newSalusAwsBridge(Thing thing) {
+ var handler = new AwsCloudBridgeHandler((Bridge) thing, httpClientFactory);
+ registerThingDiscovery(handler);
return handler;
}
- private synchronized void registerThingDiscovery(DiscoveryService discoveryService) {
- bundleContext.registerService(DiscoveryService.class.getName(), discoveryService, new Hashtable<>());
+ 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();
}
}
--- /dev/null
+/**
+ * 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='<SECRET>'" + //
+ ", url='" + url + '\'' + //
+ ", refreshInterval=" + refreshInterval + //
+ ", propertiesRefreshInterval=" + propertiesRefreshInterval + //
+ ", maxHttpRetries=" + maxHttpRetries + //
+ '}';
+ }
+}
--- /dev/null
+/**
+ * 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<AwsCloudBridgeConfig> {
+ 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<String> 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";
+ }
+}
--- /dev/null
+/**
+ * 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() + "}";
+ }
+}
--- /dev/null
+/**
+ * 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));
+ }
+}
--- /dev/null
+/**
+ * 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;
+ }
+}
--- /dev/null
+/**
+ * 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<Authentication> {
+ 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<Device> findDevices() throws AuthSalusApiException, SalusApiException {
+ var result = new TreeSet<Device>();
+ 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<String> 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<DeviceProperty<?>> 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");
+ }
+}
--- /dev/null
+/**
+ * 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<String, String> challengeParameters = Map.of();
+
+ private String getChallengeParameter(String key) {
+ return Objects.requireNonNullElse(challengeParameters.get(key), "");
+ }
+
+ public String getSalt() {
+ return getChallengeParameter("SALT");
+ }
+
+ public String getSecretBlock() {
+ return getChallengeParameter("SECRET_BLOCK");
+ }
+
+ public String getSrpB() {
+ return getChallengeParameter("SRP_B");
+ }
+
+ public String getUsername() {
+ return getChallengeParameter("USERNAME");
+ }
+
+ public String getUserIdForSrp() {
+ return getChallengeParameter("USER_ID_FOR_SRP");
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-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() + "}";
+ }
+}
--- /dev/null
+/**
+ * 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 = "";
+}
--- /dev/null
+/**
+ * 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();
+}
--- /dev/null
+/**
+ * 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 + //
+ '}';
+ }
+}
--- /dev/null
+/**
+ * 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 + '\'' + //
+ '}';
+ }
+}
--- /dev/null
+/**
+ * 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<String, String> authParameters = new TreeMap<>();
+
+ InitiateAuthRequest(String authFlow, String clientId, Map<String, String> authParameters) {
+ this.authFlow = authFlow;
+ this.clientId = clientId;
+ this.authParameters.putAll(authParameters);
+ }
+
+ public static InitiateAuthRequest userSrpAuth(String clientId, String username, String srpA) {
+ return new InitiateAuthRequest("USER_SRP_AUTH", clientId, Map.of("USERNAME", username, "SRP_A", srpA));
+ }
+
+ public static InitiateAuthRequest refreshTokenAuth(String clientId, String refreshToken) {
+ return new InitiateAuthRequest("REFRESH_TOKEN_AUTH", clientId, Map.of("REFRESH_TOKEN", refreshToken));
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-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());
+ }
+}
--- /dev/null
+/**
+ * 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<String, String> challengeResponses = new LinkedHashMap<>();
+
+ public RespondToAuthChallengeRequest(String clientId, String username, String passwordClaimSecretBlock,
+ String passwordClaimSignature, String timestamp) {
+ this.clientId = clientId;
+ challengeResponses.put("USERNAME", username);
+ challengeResponses.put("PASSWORD_CLAIM_SECRET_BLOCK", passwordClaimSecretBlock);
+ challengeResponses.put("PASSWORD_CLAIM_SIGNATURE", passwordClaimSignature);
+ challengeResponses.put("TIMESTAMP", timestamp);
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-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='<SECRET>'" + ", url='" + url + '\''
+ + ", refreshInterval=" + refreshInterval + ", propertiesRefreshInterval=" + propertiesRefreshInterval
+ + '}';
+ }
+}
--- /dev/null
+/**
+ * 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<CloudBridgeConfig> 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<String> 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";
+ }
+}
--- /dev/null
+/**
+ * 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.rest;
+
+import java.util.Objects;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Martin Grześlowski - Initial contribution
+ */
+public record AuthToken(@SerializedName("access_token") String accessToken,
+ @SerializedName("refresh_token") String refreshToken, @SerializedName("expires_in") Long expiresIn,
+ @SerializedName("role") String role) {
+ public AuthToken {
+ Objects.requireNonNull(accessToken, "accessToken cannot be null!");
+ Objects.requireNonNull(refreshToken, "refreshToken cannot be null!");
+ }
+
+ @Override
+ public String toString() {
+ return "AuthToken{" + "accessToken='<SECRET>'" + ", refreshToken='<SECRET>'" + ", expiresIn=" + expiresIn
+ + ", role='" + role + '\'' + '}';
+ }
+}
--- /dev/null
+/**
+ * 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.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.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
+ * 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 HttpSalusApi extends AbstractSalusApi<AuthToken> {
+ private static final int MAX_RETRIES = 3;
+ private static final long TOKEN_EXPIRE_TIME_ADJUSTMENT_SECONDS = 3;
+ @Nullable
+ private AuthToken authToken;
+
+ public HttpSalusApi(String username, byte[] password, String baseUrl, RestClient restClient, GsonMapper mapper,
+ Clock clock) {
+ super(username, password, baseUrl, restClient, mapper, clock);
+ }
+
+ public HttpSalusApi(String username, byte[] password, String baseUrl, RestClient restClient, GsonMapper mapper) {
+ super(username, password, baseUrl, restClient, mapper, Clock.systemDefaultZone());
+ }
+
+ @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, headers);
+ } catch (HttpSalusApiException ex) {
+ if (ex.getCode() == 401) {
+ if (retryAttempt <= MAX_RETRIES) {
+ forceRefreshAccessToken();
+ return get(url, retryAttempt + 1, headers);
+ }
+ logger.debug("Could not refresh access token after {} retries", MAX_RETRIES);
+ }
+ throw ex;
+ }
+ }
+
+ private @Nullable String post(String url, RestClient.Content content, int retryAttempt,
+ RestClient.Header... headers) throws SalusApiException, AuthSalusApiException {
+ refreshAccessToken();
+ try {
+ return restClient.post(url, content, headers);
+ } catch (HttpSalusApiException ex) {
+ if (ex.getCode() == 401) {
+ if (retryAttempt <= MAX_RETRIES) {
+ forceRefreshAccessToken();
+ return post(url, content, retryAttempt + 1, headers);
+ }
+ logger.debug("Could not refresh access token after {} retries", MAX_RETRIES);
+ }
+ throw ex;
+ }
+ }
+
+ @Override
+ protected void login() throws SalusApiException {
+ login(1);
+ }
+
+ private void login(int retryAttempt) throws SalusApiException {
+ logger.debug("Login with username '{}', retryAttempt={}", username, retryAttempt);
+ authToken = null;
+ authTokenExpireTime = null;
+ var finalUrl = url("/users/sign_in.json");
+ var inputBody = mapper.loginParam(username, password);
+ try {
+ var response = restClient.post(finalUrl, new RestClient.Content(inputBody, "application/json"),
+ new RestClient.Header("Accept", "application/json"));
+ if (response == null) {
+ throw new HttpSalusApiException(401, "No response token from server");
+ }
+ var token = authToken = mapper.authToken(response);
+ 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(retryAttempt + 1);
+ }
+ throw ex;
+ }
+ throw ex;
+ }
+ }
+
+ private void forceRefreshAccessToken() throws AuthSalusApiException {
+ logger.debug("Force refresh access token");
+ cleanAuth();
+ refreshAccessToken();
+ }
+
+ @Override
+ public SortedSet<Device> findDevices() throws SalusApiException, AuthSalusApiException {
+ refreshAccessToken();
+ var response = get(url("/apiv1/devices.json"), authHeader());
+ return new TreeSet<>(mapper.parseDevices(requireNonNull(response)));
+ }
+
+ private RestClient.Header authHeader() {
+ return new RestClient.Header("Authorization", "auth_token " + requireNonNull(authToken).accessToken());
+ }
+
+ @Override
+ public SortedSet<DeviceProperty<?>> findDeviceProperties(String dsn)
+ throws SalusApiException, AuthSalusApiException {
+ refreshAccessToken();
+ 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));
+ }
+
+ @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), 1, authHeader());
+ var datapointValue = mapper.datapointValue(response);
+ return datapointValue.orElseThrow(() -> new HttpSalusApiException(404, "No datapoint in return"));
+ }
+}
+++ /dev/null
-/**
- * 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.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 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.core.config.discovery.AbstractDiscoveryService;
-import org.openhab.core.config.discovery.DiscoveryResult;
-import org.openhab.core.config.discovery.DiscoveryResultBuilder;
-import org.openhab.core.thing.ThingTypeUID;
-import org.openhab.core.thing.ThingUID;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * @author Martin Grześlowski - Initial contribution
- */
-@NonNullByDefault
-public class CloudDiscovery extends AbstractDiscoveryService {
- private final Logger logger = LoggerFactory.getLogger(CloudDiscovery.class);
- private final CloudApi cloudApi;
- private final ThingUID bridgeUid;
-
- public CloudDiscovery(CloudBridgeHandler bridgeHandler, CloudApi cloudApi, ThingUID bridgeUid)
- throws IllegalArgumentException {
- super(SUPPORTED_THING_TYPES_UIDS, 10, true);
- this.cloudApi = cloudApi;
- this.bridgeUid = bridgeUid;
- }
-
- @Override
- protected void startScan() {
- try {
- var devices = cloudApi.findDevices();
- logger.debug("Found {} devices while scanning", devices.size());
- devices.stream().filter(Device::isConnected).forEach(this::addThing);
- } catch (SalusApiException e) {
- logger.warn("Error while scanning", e);
- stopScan();
- }
- }
-
- private void addThing(Device device) {
- logger.debug("Adding device \"{}\" ({}) to found things", device.name(), device.dsn());
- var thingUID = new ThingUID(findDeviceType(device), bridgeUid, device.dsn());
- var discoveryResult = createDiscoveryResult(thingUID, buildThingLabel(device), buildThingProperties(device));
- thingDiscovered(discoveryResult);
- }
-
- private static ThingTypeUID findDeviceType(Device device) {
- var props = device.properties();
- if (props.containsKey(OEM_MODEL)) {
- var model = props.get(OEM_MODEL);
- if (model != null) {
- if (model.toString().toLowerCase(Locale.ENGLISH).contains(IT_600)) {
- return SALUS_IT600_DEVICE_TYPE;
- }
- }
- }
- return SALUS_DEVICE_TYPE;
- }
-
- private DiscoveryResult createDiscoveryResult(ThingUID thingUID, String label, Map<String, Object> properties) {
- return DiscoveryResultBuilder.create(thingUID).withBridge(bridgeUid).withProperties(properties).withLabel(label)
- .withRepresentationProperty(DSN).build();
- }
-
- private String buildThingLabel(Device device) {
- var name = device.name();
- return (!"".equals(name)) ? name : device.dsn();
- }
-
- private Map<String, Object> buildThingProperties(Device device) {
- return Map.of(DSN, device.dsn());
- }
-}
--- /dev/null
+/**
+ * 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.discovery;
+
+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.rest.Device;
+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;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.ThingUID;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * @author Martin Grześlowski - Initial contribution
+ */
+@NonNullByDefault
+public class SalusDiscovery extends AbstractDiscoveryService {
+ private final Logger logger = LoggerFactory.getLogger(SalusDiscovery.class);
+ private final CloudApi cloudApi;
+ private final ThingUID bridgeUid;
+
+ public SalusDiscovery(CloudApi cloudApi, ThingUID bridgeUid) throws IllegalArgumentException {
+ super(SUPPORTED_THING_TYPES_UIDS, 10, true);
+ this.cloudApi = cloudApi;
+ this.bridgeUid = bridgeUid;
+ }
+
+ @Override
+ protected void startScan() {
+ try {
+ var devices = cloudApi.findDevices();
+ logger.debug("Found {} devices while scanning", devices.size());
+ devices.stream().filter(Device::connected).forEach(this::addThing);
+ } catch (SalusApiException | AuthSalusApiException e) {
+ logger.warn("Error while scanning", e);
+ stopScan();
+ }
+ }
+
+ private void addThing(Device device) {
+ logger.debug("Adding device \"{}\" ({}) to found things", device.name(), device.dsn());
+ var thingUID = new ThingUID(findDeviceType(device), bridgeUid, device.dsn());
+ var discoveryResult = createDiscoveryResult(thingUID, buildThingLabel(device), buildThingProperties(device));
+ thingDiscovered(discoveryResult);
+ }
+
+ private static ThingTypeUID findDeviceType(Device device) {
+ // cloud device
+ var props = device.properties();
+ if (props.containsKey(OEM_MODEL)) {
+ var model = props.get(OEM_MODEL);
+ if (model != null) {
+ if (model.toString().toLowerCase(Locale.ENGLISH).contains(IT_600)) {
+ return SALUS_IT600_DEVICE_TYPE;
+ }
+ }
+ }
+ // aws device
+ if (device.dsn().contains(IT_600)) {
+ return SALUS_IT600_DEVICE_TYPE;
+ }
+ return SALUS_DEVICE_TYPE;
+ }
+
+ private DiscoveryResult createDiscoveryResult(ThingUID thingUID, String label, Map<String, Object> properties) {
+ return DiscoveryResultBuilder.create(thingUID).withBridge(bridgeUid).withProperties(properties).withLabel(label)
+ .withRepresentationProperty(DSN).build();
+ }
+
+ private String buildThingLabel(Device device) {
+ var name = device.name();
+ return (!"".equals(name)) ? name : device.dsn();
+ }
+
+ private Map<String, Object> buildThingProperties(Device device) {
+ return Map.of(DSN, device.dsn());
+ }
+}
--- /dev/null
+/**
+ * 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.handler;
+
+import static org.openhab.binding.salus.internal.SalusBindingConstants.SalusCloud.DEFAULT_URL;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * @author Martin Grześlowski - Initial contribution
+ */
+@NonNullByDefault
+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 AbstractBridgeConfig() {
+ }
+
+ 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;
+ this.maxHttpRetries = maxHttpRetries;
+ }
+
+ public boolean isValid() {
+ return !username.isBlank() && !password.isBlank();
+ }
+
+ 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;
+ }
+}
--- /dev/null
+/**
+ * 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.handler;
+
+import static java.util.Objects.requireNonNull;
+import static java.util.concurrent.TimeUnit.*;
+import static org.openhab.core.thing.ThingStatus.OFFLINE;
+import static org.openhab.core.thing.ThingStatus.ONLINE;
+import static org.openhab.core.thing.ThingStatusDetail.*;
+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;
+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.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;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.binding.BaseBridgeHandler;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.types.Command;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.github.benmanes.caffeine.cache.LoadingCache;
+
+/**
+ * @author Martin Grześlowski - Initial contribution
+ */
+@NonNullByDefault
+public abstract class AbstractBridgeHandler<ConfigT extends AbstractBridgeConfig> extends BaseBridgeHandler
+ implements CloudApi {
+ protected Logger logger = LoggerFactory.getLogger(this.getClass());
+ private final HttpClientFactory httpClientFactory;
+ private final Class<ConfigT> configClass;
+ @NonNullByDefault({})
+ private LoadingCache<String, SortedSet<DeviceProperty<?>>> devicePropertiesCache;
+ @Nullable
+ private SalusApi salusApi;
+ @Nullable
+ private ScheduledFuture<?> scheduledFuture;
+
+ public AbstractBridgeHandler(Bridge bridge, HttpClientFactory httpClientFactory, Class<ConfigT> configClass) {
+ super(bridge);
+ this.httpClientFactory = httpClientFactory;
+ this.configClass = configClass;
+ }
+
+ @Override
+ public void initialize() {
+ var config = this.getConfigAs(configClass);
+ if (!config.isValid()) {
+ updateStatus(OFFLINE, CONFIGURATION_ERROR, "@text/cloud-bridge-handler.initialize.username-pass-not-valid");
+ return;
+ }
+ RestClient httpClient = new HttpClient(httpClientFactory.getCommonHttpClient());
+ if (config.getMaxHttpRetries() > 0) {
+ httpClient = new RetryHttpClient(httpClient, config.getMaxHttpRetries());
+ }
+ var localSalusApi = salusApi = newSalusApi(config, httpClient, GsonMapper.INSTANCE);
+ logger = LoggerFactory
+ .getLogger(this.getClass().getName() + "[" + config.getUsername().replace(".", "_") + "]");
+
+ ScheduledExecutorService scheduledPool = ThreadPoolManager.getScheduledPool(SalusBindingConstants.BINDING_ID);
+ scheduledPool.schedule(() -> tryConnectToCloud(localSalusApi), 1, MICROSECONDS);
+
+ this.devicePropertiesCache = Caffeine.newBuilder().maximumSize(10_000)
+ .expireAfterWrite(Duration.ofSeconds(config.getPropertiesRefreshInterval()))
+ .refreshAfterWrite(Duration.ofSeconds(config.getPropertiesRefreshInterval()))
+ .build(this::findPropertiesForDevice);
+ this.scheduledFuture = scheduledPool.scheduleWithFixedDelay(this::refreshCloudDevices,
+ config.getRefreshInterval() * 2, config.getRefreshInterval(), SECONDS);
+
+ // Do NOT set state to online to prevent it to flip from online to offline
+ // check *tryConnectToCloud(SalusApi)*
+ }
+
+ protected abstract SalusApi newSalusApi(ConfigT config, RestClient httpClient, GsonMapper gsonMapper);
+
+ private void tryConnectToCloud(SalusApi localSalusApi) {
+ try {
+ localSalusApi.findDevices();
+ // there is a connection with the cloud
+ updateStatus(ONLINE);
+ } 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() + "\"]");
+ }
+ }
+
+ private void refreshCloudDevices() {
+ logger.debug("Refreshing devices from CloudBridgeHandler");
+ if (!(thing instanceof Bridge bridge)) {
+ logger.debug("No bridge, refresh cancelled");
+ return;
+ }
+ List<Thing> things = bridge.getThings();
+ for (Thing thing : things) {
+ if (!thing.isEnabled()) {
+ logger.debug("Thing {} is disabled, refresh cancelled", thing.getUID());
+ continue;
+ }
+
+ @Nullable
+ ThingHandler handler = thing.getHandler();
+ if (handler == null) {
+ logger.debug("No handler for thing {} refresh cancelled", thing.getUID());
+ continue;
+ }
+ thing.getChannels().forEach(channel -> handler.handleCommand(channel.getUID(), REFRESH));
+ }
+
+ var local = salusApi;
+ if (local != null) {
+ tryConnectToCloud(local);
+ }
+ }
+
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ // no commands in this bridge
+ logger.debug("Bridge does not support any commands to any channels. channelUID={}, command={}", channelUID,
+ command);
+ }
+
+ @Override
+ public void dispose() {
+ ScheduledFuture<?> localScheduledFuture = scheduledFuture;
+ if (localScheduledFuture != null) {
+ localScheduledFuture.cancel(true);
+ scheduledFuture = null;
+ }
+ super.dispose();
+ }
+
+ @Override
+ public SortedSet<DeviceProperty<?>> 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, AuthSalusApiException {
+ try {
+ @Nullable
+ SalusApi api = requireNonNull(salusApi);
+ logger.debug("Setting property {} on device {} to value {} using salusClient", propertyName, dsn, value);
+ Object setValue = api.setValueForProperty(dsn, propertyName, value);
+ if ((!(setValue instanceof Boolean) && !(setValue instanceof String) && !(setValue instanceof Number))) {
+ logger.warn(
+ "Cannot set value {} ({}) for property {} on device {} because it is not a Boolean, String, Long or Integer",
+ setValue, setValue.getClass().getSimpleName(), propertyName, dsn);
+ return false;
+ }
+ var properties = devicePropertiesCache.get(dsn);
+ Optional<DeviceProperty<?>> property = requireNonNull(properties).stream()
+ .filter(prop -> prop.getName().equals(propertyName)).findFirst();
+ if (property.isEmpty()) {
+ String simpleName = setValue.getClass().getSimpleName();
+ logger.warn(
+ "Cannot set value {} ({}) for property {} on device {} because it is not found in the cache. Invalidating cache",
+ setValue, simpleName, propertyName, dsn);
+ devicePropertiesCache.invalidate(dsn);
+ return false;
+ }
+ DeviceProperty<?> prop = property.get();
+ if (setValue instanceof Boolean b && prop instanceof DeviceProperty.BooleanDeviceProperty boolProp) {
+ boolProp.setValue(b);
+ return true;
+ }
+ if (setValue instanceof String s && prop instanceof DeviceProperty.StringDeviceProperty stringProp) {
+ stringProp.setValue(s);
+ return true;
+ }
+ if (setValue instanceof Number l && prop instanceof DeviceProperty.LongDeviceProperty longProp) {
+ longProp.setValue(l.longValue());
+ return true;
+ }
+
+ logger.warn(
+ "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 (AuthSalusApiException | SalusApiException ex) {
+ devicePropertiesCache.invalidateAll();
+ throw ex;
+ }
+ }
+
+ @Override
+ public SortedSet<Device> findDevices() throws SalusApiException, AuthSalusApiException {
+ return requireNonNull(this.salusApi).findDevices();
+ }
+
+ @Override
+ public Optional<Device> findDevice(String dsn) throws SalusApiException, AuthSalusApiException {
+ return findDevices().stream().filter(device -> device.dsn().equals(dsn)).findFirst();
+ }
+
+ public abstract Set<String> it600RequiredChannels();
+
+ public abstract String channelPrefix();
+}
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
*
* @return all devices from cloud
*/
- SortedSet<Device> findDevices() throws SalusApiException;
+ SortedSet<Device> findDevices() throws SalusApiException, AuthSalusApiException;
/**
* Find a device by DSN
* @param dsn of the device to find
* @return a device with given DSN (or empty if no found)
*/
- Optional<Device> findDevice(String dsn) throws SalusApiException;
+ Optional<Device> findDevice(String dsn) throws SalusApiException, AuthSalusApiException;
/**
* Sets value for a property
* @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
* @param dsn of the device
* @return all properties of the device
*/
- SortedSet<DeviceProperty<?>> findPropertiesForDevice(String dsn) throws SalusApiException;
+ SortedSet<DeviceProperty<?>> findPropertiesForDevice(String dsn) throws SalusApiException, AuthSalusApiException;
+
+ boolean isReadOnly();
}
+++ /dev/null
-/**
- * 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.handler;
-
-import static org.openhab.binding.salus.internal.SalusBindingConstants.SalusCloud.DEFAULT_URL;
-
-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 CloudBridgeConfig() {
- }
-
- public CloudBridgeConfig(String username, String password, String url, long refreshInterval,
- long propertiesRefreshInterval) {
- 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;
- }
-
- public boolean isValid() {
- return !username.isBlank() && !password.isBlank();
- }
-
- @Override
- public String toString() {
- return "CloudBridgeConfig{" + "username='" + username + '\'' + ", password='<SECRET>'" + ", url='" + url + '\''
- + ", refreshInterval=" + refreshInterval + ", propertiesRefreshInterval=" + propertiesRefreshInterval
- + '}';
- }
-}
+++ /dev/null
-/**
- * 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.handler;
-
-import static java.util.Objects.requireNonNull;
-import static java.util.concurrent.TimeUnit.*;
-import static org.openhab.core.thing.ThingStatus.OFFLINE;
-import static org.openhab.core.thing.ThingStatus.ONLINE;
-import static org.openhab.core.thing.ThingStatusDetail.*;
-import static org.openhab.core.types.RefreshType.REFRESH;
-
-import java.time.Duration;
-import java.util.List;
-import java.util.Optional;
-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.SalusBindingConstants;
-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.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.core.common.ThreadPoolManager;
-import org.openhab.core.io.net.http.HttpClientFactory;
-import org.openhab.core.thing.Bridge;
-import org.openhab.core.thing.ChannelUID;
-import org.openhab.core.thing.Thing;
-import org.openhab.core.thing.binding.BaseBridgeHandler;
-import org.openhab.core.thing.binding.ThingHandler;
-import org.openhab.core.types.Command;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import com.github.benmanes.caffeine.cache.Caffeine;
-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());
- private final HttpClientFactory httpClientFactory;
- @NonNullByDefault({})
- private LoadingCache<String, SortedSet<DeviceProperty<?>>> devicePropertiesCache;
- @Nullable
- private SalusApi salusApi;
- @Nullable
- private ScheduledFuture<?> scheduledFuture;
-
- public CloudBridgeHandler(Bridge bridge, HttpClientFactory httpClientFactory) {
- super(bridge);
- this.httpClientFactory = httpClientFactory;
- }
-
- @Override
- public void initialize() {
- CloudBridgeConfig config = this.getConfigAs(CloudBridgeConfig.class);
- if (!config.isValid()) {
- updateStatus(OFFLINE, CONFIGURATION_ERROR, "@text/cloud-bridge-handler.initialize.username-pass-not-valid");
- return;
- }
- RestClient httpClient = new HttpClient(httpClientFactory.getCommonHttpClient());
- 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);
- logger = LoggerFactory
- .getLogger(CloudBridgeHandler.class.getName() + "[" + config.getUsername().replace(".", "_") + "]");
-
- ScheduledExecutorService scheduledPool = ThreadPoolManager.getScheduledPool(SalusBindingConstants.BINDING_ID);
- scheduledPool.schedule(() -> tryConnectToCloud(localSalusApi), 1, MICROSECONDS);
-
- this.devicePropertiesCache = Caffeine.newBuilder().maximumSize(10_000)
- .expireAfterWrite(Duration.ofSeconds(config.getPropertiesRefreshInterval()))
- .refreshAfterWrite(Duration.ofSeconds(config.getPropertiesRefreshInterval()))
- .build(this::findPropertiesForDevice);
- this.scheduledFuture = scheduledPool.scheduleWithFixedDelay(this::refreshCloudDevices,
- config.getRefreshInterval() * 2, config.getRefreshInterval(), SECONDS);
-
- // Do NOT set state to online to prevent it to flip from online to offline
- // check *tryConnectToCloud(SalusApi)*
- }
-
- private void tryConnectToCloud(SalusApi localSalusApi) {
- try {
- localSalusApi.findDevices();
- // there is a connection with the cloud
- updateStatus(ONLINE);
- } catch (SalusApiException ex) {
- updateStatus(OFFLINE, COMMUNICATION_ERROR,
- "@text/cloud-bridge-handler.initialize.cannot-connect-to-cloud [\"" + ex.getMessage() + "\"]");
- }
- }
-
- private void refreshCloudDevices() {
- logger.debug("Refreshing devices from CloudBridgeHandler");
- if (!(thing instanceof Bridge bridge)) {
- logger.debug("No bridge, refresh cancelled");
- return;
- }
- List<Thing> things = bridge.getThings();
- for (Thing thing : things) {
- if (!thing.isEnabled()) {
- logger.debug("Thing {} is disabled, refresh cancelled", thing.getUID());
- continue;
- }
-
- @Nullable
- ThingHandler handler = thing.getHandler();
- if (handler == null) {
- logger.debug("No handler for thing {} refresh cancelled", thing.getUID());
- continue;
- }
- thing.getChannels().forEach(channel -> handler.handleCommand(channel.getUID(), REFRESH));
- }
-
- var local = salusApi;
- if (local != null) {
- tryConnectToCloud(local);
- }
- }
-
- @Override
- public void handleCommand(ChannelUID channelUID, Command command) {
- // no commands in this bridge
- logger.debug("Bridge does not support any commands to any channels. channelUID={}, command={}", channelUID,
- command);
- }
-
- @Override
- public void dispose() {
- ScheduledFuture<?> localScheduledFuture = scheduledFuture;
- if (localScheduledFuture != null) {
- localScheduledFuture.cancel(true);
- scheduledFuture = null;
- }
- super.dispose();
- }
-
- @Override
- public SortedSet<DeviceProperty<?>> findPropertiesForDevice(String dsn) throws SalusApiException {
- 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 {
- try {
- @Nullable
- SalusApi api = requireNonNull(salusApi);
- logger.debug("Setting property {} on device {} to value {} using salusClient", propertyName, dsn, value);
- Object setValue = api.setValueForProperty(dsn, propertyName, value);
- if ((!(setValue instanceof Boolean) && !(setValue instanceof String) && !(setValue instanceof Number))) {
- logger.warn(
- "Cannot set value {} ({}) for property {} on device {} because it is not a Boolean, String, Long or Integer",
- setValue, setValue.getClass().getSimpleName(), propertyName, dsn);
- return false;
- }
- var properties = devicePropertiesCache.get(dsn);
- Optional<DeviceProperty<?>> property = requireNonNull(properties).stream()
- .filter(prop -> prop.getName().equals(propertyName)).findFirst();
- if (property.isEmpty()) {
- String simpleName = setValue.getClass().getSimpleName();
- logger.warn(
- "Cannot set value {} ({}) for property {} on device {} because it is not found in the cache. Invalidating cache",
- setValue, simpleName, propertyName, dsn);
- devicePropertiesCache.invalidate(dsn);
- return false;
- }
- DeviceProperty<?> prop = property.get();
- if (setValue instanceof Boolean b && prop instanceof DeviceProperty.BooleanDeviceProperty boolProp) {
- boolProp.setValue(b);
- return true;
- }
- if (setValue instanceof String s && prop instanceof DeviceProperty.StringDeviceProperty stringProp) {
- stringProp.setValue(s);
- return true;
- }
- if (setValue instanceof Number l && prop instanceof DeviceProperty.LongDeviceProperty longProp) {
- longProp.setValue(l.longValue());
- return true;
- }
-
- logger.warn(
- "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) {
- devicePropertiesCache.invalidateAll();
- throw ex;
- }
- }
-
- @Override
- public SortedSet<Device> findDevices() throws SalusApiException {
- return requireNonNull(this.salusApi).findDevices();
- }
-
- @Override
- public Optional<Device> findDevice(String dsn) throws SalusApiException {
- return findDevices().stream().filter(device -> device.dsn().equals(dsn)).findFirst();
- }
-}
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;
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;
}
"@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;
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
+ if (command != REFRESH && cloudApi.isReadOnly()) {
+ return;
+ }
try {
if (command instanceof RefreshType) {
handleRefreshCommand(channelUID);
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;
updateState(channelUID, state);
}
- private SortedSet<DeviceProperty<?>> findDeviceProperties() throws SalusApiException {
+ private SortedSet<DeviceProperty<?>> 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)) {
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;
}
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)) {
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;
@NonNullByDefault
public class It600Handler extends BaseThingHandler {
private static final BigDecimal ONE_HUNDRED = new BigDecimal(100);
- private static final Set<String> 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);
@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);
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;
// 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,
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
+ if (command != REFRESH && cloudApi.isReadOnly()) {
+ return;
+ }
try {
var id = channelUID.getId();
switch (id) {
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 -> {
});
}
- 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 -> {
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;
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";
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;
}
}
private Optional<DeviceProperty.LongDeviceProperty> 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)
return property;
}
- private SortedSet<DeviceProperty<?>> findDeviceProperties() throws SalusApiException {
+ private SortedSet<DeviceProperty<?>> findDeviceProperties() throws SalusApiException, AuthSalusApiException {
return this.cloudApi.findPropertiesForDevice(dsn);
}
}
--- /dev/null
+/**
+ * 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<AuthT> 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;
+ }
+}
+++ /dev/null
-/**
- * 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.util.Objects;
-
-import com.google.gson.annotations.SerializedName;
-
-/**
- * @author Martin Grześlowski - Initial contribution
- */
-public record AuthToken(@SerializedName("access_token") String accessToken,
- @SerializedName("refresh_token") String refreshToken, @SerializedName("expires_in") Long expiresIn,
- @SerializedName("role") String role) {
- public AuthToken {
- Objects.requireNonNull(accessToken, "accessToken cannot be null!");
- Objects.requireNonNull(refreshToken, "refreshToken cannot be null!");
- }
-
- @Override
- public String toString() {
- return "AuthToken{" + "accessToken='<SECRET>'" + ", refreshToken='<SECRET>'" + ", expiresIn=" + expiresIn
- + ", role='" + role + '\'' + '}';
- }
-}
/**
* @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<Device> {
public Device {
requireNonNull(dsn, "DSN is required!");
requireNonNull(name, "name is required!");
requireNonNull(properties, "properties is required!");
+
+ dsn = dsn.trim();
+ name = name.trim();
}
@Override
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) {
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;
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;
* 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
};
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))));
}
.filter(Optional::isPresent).map(Optional::get).toList();
}
+ @SuppressWarnings("unchecked")
+ public List<Device> 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<String, Object> data;
+ if (rawData instanceof Map<?, ?> dataMap) {
+ data = (Map<String, Object>) dataMap;
+ } else {
+ data = tryParseBody(rawData.toString(), MAP_TYPE_REFERENCE, Map.of());
+ }
+ if (!data.containsKey("items")) {
+ return List.of();
+ }
+
+ var rawItems = data.get("items");
+ List<Object> items;
+ if (rawItems instanceof List<?>) {
+ items = (List<Object>) 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<Device> parseDevice(Object obj) {
if (!(obj instanceof Map<?, ?> firstLevelMap)) {
logger.debug("Cannot parse device, because object is not type of map!\n{}", obj);
}
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<Device> 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")
return Collections.unmodifiableList(deviceProperties);
}
+ @SuppressWarnings("unchecked")
+ public List<DeviceProperty<?>> 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<String, Object> state;
+ if (rawState instanceof Map<?, ?> stateMap) {
+ state = (Map<String, Object>) stateMap;
+ } else {
+ state = tryParseBody(rawState.toString(), MAP_TYPE_REFERENCE, Map.of());
+ }
+ if (!state.containsKey("reported")) {
+ return List.of();
+ }
+
+ var rawReported = state.get("reported");
+ Map<String, Object> reported;
+ if (rawReported instanceof Map<?, ?> reportedMap) {
+ reported = (Map<String, Object>) reportedMap;
+ } else {
+ reported = tryParseBody(rawReported.toString(), MAP_TYPE_REFERENCE, Map.of());
+ }
+ if (!reported.containsKey("11")) {
+ return List.of();
+ }
+
+ var rawEleven = reported.get("11");
+ Map<String, Object> eleven;
+ if (rawEleven instanceof Map<?, ?> elevenMap) {
+ eleven = (Map<String, Object>) elevenMap;
+ } else {
+ eleven = tryParseBody(rawEleven.toString(), MAP_TYPE_REFERENCE, Map.of());
+ }
+ if (!eleven.containsKey("properties")) {
+ return List.of();
+ }
+
+ var deviceProperties = new ArrayList<DeviceProperty<?>>();
+ var rawProperties = eleven.get("properties");
+ Map<String, Object> properties;
+ if (rawProperties instanceof Map<?, ?> propertiesMap) {
+ properties = (Map<String, Object>) 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<DeviceProperty<?>> parseDeviceProperty(@Nullable Object obj) {
if (!(obj instanceof Map<?, ?> firstLevelMap)) {
logger.debug("Cannot parse device property, because object is not type of map!\n{}", obj);
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,
return Optional.ofNullable(datapoint.get("value"));
}
+ public List<String> 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<Object> 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<String> parseAwsGatewayId(Object json) {
+ Map<String, Object> map;
+ if (json instanceof Map<?, ?>) {
+ map = (Map<String, Object>) 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, @Nullable V> (K key, @Nullable V value) {
}
}
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
+++ /dev/null
-/**
- * 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.io.Serial;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-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;
- private final int code;
- private final String msg;
-
- public HttpSalusApiException(int code, String msg, HttpResponseException e) {
- super("HTTP Error %s: %s".formatted(code, msg), e);
- this.code = code;
- this.msg = msg;
- }
-
- public HttpSalusApiException(int code, String msg) {
- super("HTTP Error %s: %s".formatted(code, msg));
- this.code = code;
- this.msg = msg;
- }
-
- public int getCode() {
- return code;
- }
-
- public String getMsg() {
- return msg;
- }
-}
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
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;
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;
}
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;
}
+++ /dev/null
-/**
- * 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 static java.util.Objects.requireNonNull;
-
-import java.time.Clock;
-import java.time.LocalDateTime;
-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;
-
-/**
- * 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 SalusApi {
- 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,
- 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(".", "_") + "]");
- }
-
- public SalusApi(String username, char[] password, String baseUrl, RestClient restClient, GsonMapper mapper) {
- this(username, password, baseUrl, restClient, mapper, Clock.systemDefaultZone());
- }
-
- private @Nullable String get(String url, RestClient.Header header, int retryAttempt) throws SalusApiException {
- refreshAccessToken();
- try {
- return restClient.get(url, authHeader());
- } catch (HttpSalusApiException ex) {
- if (ex.getCode() == 401) {
- if (retryAttempt <= MAX_RETRIES) {
- forceRefreshAccessToken();
- return get(url, header, retryAttempt + 1);
- }
- logger.debug("Could not refresh access token after {} retries", MAX_RETRIES);
- }
- throw ex;
- }
- }
-
- private @Nullable String post(String url, RestClient.Content content, RestClient.Header header, int retryAttempt)
- throws SalusApiException {
- refreshAccessToken();
- try {
- return restClient.post(url, content, header);
- } catch (HttpSalusApiException ex) {
- if (ex.getCode() == 401) {
- if (retryAttempt <= MAX_RETRIES) {
- forceRefreshAccessToken();
- return post(url, content, header, retryAttempt + 1);
- }
- logger.debug("Could not refresh access token after {} retries", MAX_RETRIES);
- }
- throw ex;
- }
- }
-
- private static String removeTrailingSlash(String str) {
- if (str.endsWith("/")) {
- return str.substring(0, str.length() - 1);
- }
- return str;
- }
-
- private void login(String username, char[] password) throws SalusApiException {
- login(username, password, 1);
- }
-
- private void login(String username, char[] password, int retryAttempt) throws SalusApiException {
- logger.debug("Login with username '{}', retryAttempt={}", username, retryAttempt);
- authToken = null;
- authTokenExpireTime = null;
- var finalUrl = url("/users/sign_in.json");
- var inputBody = mapper.loginParam(username, password);
- try {
- var response = restClient.post(finalUrl, new RestClient.Content(inputBody, "application/json"),
- new RestClient.Header("Accept", "application/json"));
- if (response == null) {
- throw new HttpSalusApiException(401, "No response token from server");
- }
- var token = authToken = mapper.authToken(response);
- authTokenExpireTime = 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);
- 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);
- }
- throw ex;
- }
- throw ex;
- }
- }
-
- private void forceRefreshAccessToken() throws SalusApiException {
- logger.debug("Force refresh access token");
- authToken = null;
- authTokenExpireTime = null;
- 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<Device> findDevices() throws SalusApiException {
- refreshAccessToken();
- var response = get(url("/apiv1/devices.json"), authHeader(), 1);
- return new TreeSet<>(mapper.parseDevices(requireNonNull(response)));
- }
-
- private RestClient.Header authHeader() {
- return new RestClient.Header("Authorization", "auth_token " + requireNonNull(authToken).accessToken());
- }
-
- public SortedSet<DeviceProperty<?>> findDeviceProperties(String dsn) throws SalusApiException {
- refreshAccessToken();
- var response = get(url("/apiv1/dsns/" + dsn + "/properties.json"), authHeader(), 1);
- 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 {
- 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 datapointValue = mapper.datapointValue(response);
- return datapointValue.orElseThrow(() -> new HttpSalusApiException(404, "No datapoint in return"));
- }
-}
+++ /dev/null
-/**
- * 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.io.Serial;
-
-/**
- * @author Martin Grześlowski - Initial contribution
- */
-@SuppressWarnings("SerializableHasSerializationMethods")
-public class SalusApiException extends Exception {
- @Serial
- private static final long serialVersionUID = 1L;
-
- public SalusApiException(String msg, Exception e) {
- super(msg, e);
- }
-
- public SalusApiException(String msg) {
- super(msg);
- }
-}
--- /dev/null
+/**
+ * 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);
+ }
+}
--- /dev/null
+/**
+ * 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;
+import org.eclipse.jetty.client.HttpResponseException;
+
+/**
+ * @author Martin Grześlowski - Initial contribution
+ */
+@NonNullByDefault
+public class HttpSalusApiException extends SalusApiException {
+ @Serial
+ private static final long serialVersionUID = 1L;
+ private final int code;
+ private final String msg;
+
+ public HttpSalusApiException(int code, String msg, HttpResponseException e) {
+ super("HTTP Error %s: %s".formatted(code, msg), e);
+ this.code = code;
+ this.msg = msg;
+ }
+
+ public HttpSalusApiException(int code, String msg) {
+ super("HTTP Error %s: %s".formatted(code, msg));
+ this.code = code;
+ this.msg = msg;
+ }
+
+ public int getCode() {
+ return code;
+ }
+
+ public String getMsg() {
+ return msg;
+ }
+}
--- /dev/null
+/**
+ * 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 SalusApiException extends Exception {
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ public SalusApiException(String msg, Exception e) {
+ super(msg, e);
+ }
+
+ public SalusApiException(String msg) {
+ super(msg);
+ }
+}
--- /dev/null
+/**
+ * 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);
+ }
+}
# 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
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.
# 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
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.
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="salus"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xmlns:thing="http://eclipse.org/smarthome/schemas/thing-description/v1.0.0"
+ xsi:schemaLocation="http://eclipse.org/smarthome/schemas/thing-description/v1.0.0
+ org.eclipse.smarthome.thing-description.xsd">
+
+ <bridge-type id="salus-aws-bridge">
+ <label>AWS Salus Cloud</label>
+ <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.
+ </description>
+
+ <representation-property>username</representation-property>
+ <config-description>
+ <parameter-group name="aws">
+ <label>AWS</label>
+ <description>AWS Properties</description>
+ </parameter-group>
+ <parameter name="username" type="text" required="true">
+ <label>Username/Email</label>
+ <description>The username or email associated with your Salus account. This is required for authentication with the
+ Salus cloud.</description>
+ </parameter>
+ <parameter name="password" type="text" required="true">
+ <label>Password</label>
+ <context>password</context>
+ <description>The password for your Salus account. This is used in conjunction with the username or email for
+ authentication purposes.</description>
+ </parameter>
+ <parameter name="url" type="text" required="true">
+ <label>Salus API URL</label>
+ <default>https://service-api.eu.premium.salusconnect.io</default>
+ <advanced>true</advanced>
+ <context>url</context>
+ <description>The base URL for the Salus cloud. Typically, this should remain as the default, unless directed to
+ change by Salus.</description>
+ </parameter>
+ <parameter name="refreshInterval" type="integer" required="false" min="1" max="600" unit="s">
+ <label>Refresh Interval</label>
+ <description>The interval in seconds at which the connection to the Salus cloud should be refreshed to ensure
+ up-to-date data.</description>
+ <advanced>true</advanced>
+ <default>30</default>
+ </parameter>
+ <parameter name="propertiesRefreshInterval" type="integer" required="false" min="1" max="600" unit="s">
+ <label>Device Property Cache Expiration</label>
+ <description>The period (in seconds) after which the cached device properties will be discarded and re-fetched fresh
+ from the Salus cloud.</description>
+ <advanced>true</advanced>
+ <default>5</default>
+ </parameter>
+ <parameter name="maxHttpRetries" type="integer" required="false">
+ <label>Max HTTP Retries</label>
+ <description>How many times HTTP requests can be retried</description>
+ <advanced>true</advanced>
+ <default>3</default>
+ </parameter>
+ <parameter name="userPoolId" type="text" groupName="aws">
+ <label>User Pool ID</label>
+ <default>eu-central-1_XGRz3CgoY</default>
+ <advanced>true</advanced>
+ </parameter>
+ <parameter name="identityPoolId" type="text" groupName="aws">
+ <label>Identity Pool ID</label>
+ <default>60912c00-287d-413b-a2c9-ece3ccef9230</default>
+ <advanced>true</advanced>
+ </parameter>
+ <parameter name="clientId" type="text" groupName="aws">
+ <label>Client ID</label>
+ <description>
+ The app client ID
+ </description>
+ <default>4pk5efh3v84g5dav43imsv4fbj</default>
+ <advanced>true</advanced>
+ </parameter>
+ <parameter name="region" type="text" groupName="aws">
+ <label>Region</label>
+ <description>
+ Region with which the SDK should communicate
+ </description>
+ <default>eu-central-1</default>
+ <advanced>true</advanced>
+ </parameter>
+ <parameter name="companyCode" type="text" groupName="aws">
+ <label>Company Code</label>
+ <default>salus-eu</default>
+ <advanced>true</advanced>
+ </parameter>
+ <parameter name="awsService" type="text" groupName="aws">
+ <label>AWS Service</label>
+ <default>a24u3z7zzwrtdl-ats</default>
+ <advanced>true</advanced>
+ </parameter>
+ </config-description>
+
+ </bridge-type>
+</thing:thing-descriptions>
<thing-type id="salus-it600-device">
<supported-bridge-type-refs>
<bridge-type-ref id="salus-cloud-bridge"/>
+ <bridge-type-ref id="salus-aws-bridge"/>
</supported-bridge-type-refs>
<label>IT600 Salus Thermostat</label>
<thing-type id="salus-device">
<supported-bridge-type-refs>
<bridge-type-ref id="salus-cloud-bridge"/>
+ <bridge-type-ref id="salus-aws-bridge"/>
</supported-bridge-type-refs>
<label>Salus Device</label>
<description>
--- /dev/null
+/**
+ * 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.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;
+import static org.mockito.ArgumentMatchers.endsWith;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.time.Clock;
+import java.util.ArrayList;
+import java.util.Optional;
+
+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 HttpSalusApiTest {
+
+ // Find devices returns sorted set of devices
+ @Test
+ @DisplayName("Find devices returns sorted set of devices")
+ public void testFindDevicesReturnsSortedSetOfDevices() throws Exception {
+ // Given
+ var username = "correct_username";
+ 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");
+ var response = "devices_json";
+ when(restClient.get(anyString(), any())).thenReturn(response);
+
+ var devices = new ArrayList<Device>();
+ when(mapper.parseDevices(anyString())).thenReturn(devices);
+
+ var salusApi = new HttpSalusApi(username, password, baseUrl, restClient, mapper, clock);
+ setAuthToken(salusApi, restClient, mapper, authToken);
+
+ // When
+ var result = salusApi.findDevices();
+
+ // Then
+ assertThat(result).containsExactlyInAnyOrderElementsOf(devices);
+ }
+
+ // Find device properties returns sorted set of device properties
+ @Test
+ @DisplayName("Find device properties returns sorted set of device properties")
+ public void testFindDevicePropertiesReturnsSortedSetOfDeviceProperties() throws Exception {
+ // Given
+ var username = "correct_username";
+ 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");
+ var response = "device_properties_json";
+ when(restClient.get(anyString(), any())).thenReturn(response);
+
+ var deviceProperties = new ArrayList<DeviceProperty<?>>();
+ when(mapper.parseDeviceProperties(anyString())).thenReturn(deviceProperties);
+
+ var salusApi = new HttpSalusApi(username, password, baseUrl, restClient, mapper, clock);
+ setAuthToken(salusApi, restClient, mapper, authToken);
+
+ // When
+ var result = salusApi.findDeviceProperties("dsn");
+
+ // Then
+ assertThat(result).containsExactlyInAnyOrderElementsOf(deviceProperties);
+ }
+
+ // Set value for property returns OK response with datapoint value
+ @Test
+ @DisplayName("Set value for property returns OK response with datapoint value")
+ public void testSetValueForPropertyReturnsOkResponseWithDatapointValue() throws Exception {
+ // Given
+ var username = "correct_username";
+ 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");
+ var response = "datapoint_value_json";
+ when(restClient.post(anyString(), any(), any())).thenReturn(response);
+
+ var datapointValue = new Object();
+ when(mapper.datapointValue(anyString())).thenReturn(Optional.of(datapointValue));
+
+ var salusApi = new HttpSalusApi(username, password, baseUrl, restClient, mapper, clock);
+ setAuthToken(salusApi, restClient, mapper, authToken);
+
+ // When
+ var result = salusApi.setValueForProperty("dsn", "property_name", "value");
+
+ // Then
+ assertThat(result).isEqualTo(datapointValue);
+ }
+
+ // Login with incorrect credentials throws HttpUnauthorizedException
+ @Test
+ @DisplayName("Login with incorrect credentials throws HttpUnauthorizedException")
+ public void testLoginWithIncorrectCredentialsThrowsHttpUnauthorizedException() throws Exception {
+ // Given
+ var username = "incorrect_username";
+ var password = "incorrect_password".getBytes(UTF_8);
+ var baseUrl = "https://example.com";
+ var restClient = mock(RestClient.class);
+ var mapper = mock(GsonMapper.class);
+ var clock = Clock.systemDefaultZone();
+
+ when(restClient.post(anyString(), any(), any()))
+ .thenThrow(new HttpSalusApiException(401, "unauthorized_error_json"));
+
+ var salusApi = new HttpSalusApi(username, password, baseUrl, restClient, mapper, clock);
+
+ // When
+ ThrowingCallable findDevicesResponse = salusApi::findDevices;
+
+ // Then
+ assertThatThrownBy(findDevicesResponse).isInstanceOf(AuthSalusApiException.class)
+ .hasMessage("Could not log in, for user incorrect_username");
+ }
+
+ // Find devices with invalid auth token throws HttpUnauthorizedException
+ @Test
+ @DisplayName("Find devices with invalid auth token throws HttpUnauthorizedException")
+ public void testFindDevicesWithInvalidAuthTokenThrowsHttpUnauthorizedException() throws Exception {
+ // Given
+ var username = "correct_username";
+ 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.get(anyString(), any())).thenThrow(new HttpSalusApiException(401, "unauthorized_error_json"));
+
+ var salusApi = new HttpSalusApi(username, password, baseUrl, restClient, mapper, clock);
+ setAuthToken(salusApi, restClient, mapper, authToken);
+
+ // When
+ ThrowingCallable objectApiResponse = salusApi::findDevices;
+
+ // Then
+ assertThatThrownBy(objectApiResponse).isInstanceOf(HttpSalusApiException.class)
+ .hasMessage("HTTP Error 401: unauthorized_error_json");
+ }
+
+ // Find device properties with invalid auth token throws HttpUnauthorizedException
+ @Test
+ @DisplayName("Find device properties with invalid auth token throws HttpUnauthorizedException")
+ public void testFindDevicePropertiesWithInvalidAuthTokenThrowsHttpUnauthorizedException() throws Exception {
+ // Given
+ var username = "correct_username";
+ 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.get(anyString(), any())).thenThrow(new HttpSalusApiException(401, "unauthorized_error_json"));
+
+ var salusApi = new HttpSalusApi(username, password, baseUrl, restClient, mapper, clock);
+ setAuthToken(salusApi, restClient, mapper, authToken);
+
+ // When
+ ThrowingCallable objectApiResponse = () -> salusApi.findDeviceProperties("dsn");
+
+ // Given
+ assertThatThrownBy(objectApiResponse).isInstanceOf(HttpSalusApiException.class)
+ .hasMessage("HTTP Error 401: unauthorized_error_json");
+ }
+
+ // Set value for property with invalid auth token throws HttpUnauthorizedException
+ @Test
+ @DisplayName("Set value for property with invalid auth token throws HttpUnauthorizedException")
+ public void testSetValueForPropertyWithInvalidAuthTokenThrowsHttpUnauthorizedException() throws Exception {
+ // Given
+ var username = "correct_username";
+ 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 salusApi = new HttpSalusApi(username, password, baseUrl, restClient, mapper, clock);
+
+ // When
+ ThrowingCallable objectApiResponse = () -> salusApi.setValueForProperty("dsn", "property_name", "value");
+
+ // given
+
+ assertThatThrownBy(objectApiResponse).isInstanceOf(AuthSalusApiException.class)
+ .hasMessage("Could not log in, for user correct_username");
+ }
+
+ // Find device properties with invalid DSN returns ApiResponse with error
+ @Test
+ @DisplayName("Find device properties with invalid DSN returns ApiResponse with error")
+ public void testFindDevicePropertiesWithInvalidDsnReturnsApiResponseWithError() throws Exception {
+ // Given
+ var username = "correct_username";
+ 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.get(anyString(), any())).thenThrow(new HttpSalusApiException(404, "not found"));
+
+ var salusApi = new HttpSalusApi(username, password, baseUrl, restClient, mapper, clock);
+ setAuthToken(salusApi, restClient, mapper, authToken);
+
+ // When
+ ThrowingCallable result = () -> salusApi.findDeviceProperties("invalid_dsn");
+
+ // Then
+ assertThatThrownBy(result).isInstanceOf(HttpSalusApiException.class).hasMessage("HTTP Error 404: not found");
+ }
+
+ // Login with incorrect credentials 3 times throws HttpForbiddenException
+ @Test
+ @DisplayName("Login with incorrect credentials 3 times throws HttpForbiddenException")
+ public void testLoginWithIncorrectCredentials3TimesThrowsHttpForbiddenException() throws Exception {
+ // Given
+ var username = "incorrect_username";
+ var password = "incorrect_password".getBytes(UTF_8);
+ var baseUrl = "https://example.com";
+ var restClient = mock(RestClient.class);
+ var mapper = mock(GsonMapper.class);
+ var clock = Clock.systemDefaultZone();
+
+ when(restClient.post(anyString(), any(), any()))
+ .thenThrow(new HttpSalusApiException(403, "forbidden_error_json"));
+
+ var salusApi = new HttpSalusApi(username, password, baseUrl, restClient, mapper, clock);
+
+ // When
+ ThrowingCallable findDevicesResponse = salusApi::findDevices;
+
+ // Then
+ assertThatThrownBy(findDevicesResponse).isInstanceOf(AuthSalusApiException.class)
+ .hasMessage("Could not log in, for user incorrect_username");
+ }
+
+ private void setAuthToken(HttpSalusApi salusApi, RestClient restClient, GsonMapper mapper, AuthToken authToken)
+ throws SalusApiException {
+ var username = "correct_username";
+ var password = "correct_password".getBytes(UTF_8);
+ var inputBody = "login_param_json";
+ when(mapper.loginParam(username, password)).thenReturn(inputBody);
+ var authTokenJson = "auth_token";
+ when(mapper.authToken(authTokenJson)).thenReturn(authToken);
+
+ when(restClient.post(endsWith("/users/sign_in.json"), eq(new RestClient.Content(inputBody, "application/json")),
+ any())).thenReturn(authTokenJson);
+ }
+}
+++ /dev/null
-/**
- * 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.discovery;
-
-import static org.mockito.BDDMockito.given;
-import static org.mockito.Mockito.argThat;
-import static org.mockito.Mockito.eq;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.verify;
-
-import java.util.HashMap;
-import java.util.List;
-import java.util.Random;
-import java.util.TreeSet;
-
-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.core.config.discovery.DiscoveryListener;
-import org.openhab.core.thing.ThingUID;
-
-/**
- * @author Martin Grześlowski - Initial contribution
- */
-public class CloudDiscoveryTest {
-
- @Test
- @DisplayName("Method filters out disconnected devices and adds connected devices as things using addThing method")
- void testFiltersOutDisconnectedDevicesAndAddsConnectedDevicesAsThings() throws SalusApiException {
- // 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 discoveryListener = mock(DiscoveryListener.class);
- discoveryService.addDiscoveryListener(discoveryListener);
- var device1 = randomDevice(true);
- var device2 = randomDevice(true);
- var device3 = randomDevice(false);
- var device4 = randomDevice(false);
- var devices = new TreeSet<>(List.of(device1, device2, device3, device4));
-
- given(cloudApi.findDevices()).willReturn(devices);
-
- // When
- discoveryService.startScan();
-
- // Then
- verify(cloudApi).findDevices();
- verify(discoveryListener).thingDiscovered(eq(discoveryService),
- argThat(discoveryResult -> discoveryResult.getLabel().equals(device1.name())));
- verify(discoveryListener).thingDiscovered(eq(discoveryService),
- argThat(discoveryResult -> discoveryResult.getLabel().equals(device2.name())));
- verify(discoveryListener, never()).thingDiscovered(eq(discoveryService),
- argThat(discoveryResult -> discoveryResult.getLabel().equals(device3.name())));
- verify(discoveryListener, never()).thingDiscovered(eq(discoveryService),
- argThat(discoveryResult -> discoveryResult.getLabel().equals(device4.name())));
- }
-
- @Test
- @DisplayName("Cloud API throws an exception during device retrieval, method logs the error")
- void testLogsErrorWhenCloudApiThrowsException() throws SalusApiException {
- // Given
- var cloudApi = mock(CloudApi.class);
- var bridgeHandler = mock(CloudBridgeHandler.class);
- var bridgeUid = mock(ThingUID.class);
- var discoveryService = new CloudDiscovery(bridgeHandler, cloudApi, bridgeUid);
-
- given(cloudApi.findDevices()).willThrow(new SalusApiException("API error"));
-
- // When
- discoveryService.startScan();
-
- // Then
- // no error is thrown, OK
- }
-
- private Device randomDevice(boolean connected) {
- var random = new Random();
- var map = new HashMap<String, Object>();
- if (connected) {
- map.put("connection_status", "online");
- }
- return new Device("dsn-" + random.nextInt(), "name-" + random.nextInt(), map);
- }
-}
--- /dev/null
+/**
+ * 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.discovery;
+
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.argThat;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+import java.util.HashMap;
+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.rest.Device;
+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
+ */
+@NonNullByDefault
+public class SalusDiscoveryTest {
+
+ @Test
+ @DisplayName("Method filters out disconnected devices and adds connected devices as things using addThing method")
+ void testFiltersOutDisconnectedDevicesAndAddsConnectedDevicesAsThings() throws Exception {
+ // Given
+ var cloudApi = mock(CloudApi.class);
+ var bridgeUid = new ThingUID("salus", "salus-device", "boo");
+ var discoveryService = new SalusDiscovery(cloudApi, bridgeUid);
+ var discoveryListener = mock(DiscoveryListener.class);
+ discoveryService.addDiscoveryListener(discoveryListener);
+ var device1 = randomDevice(true);
+ var device2 = randomDevice(true);
+ var device3 = randomDevice(false);
+ var device4 = randomDevice(false);
+ var devices = new TreeSet<>(List.of(device1, device2, device3, device4));
+
+ given(cloudApi.findDevices()).willReturn(devices);
+
+ // When
+ discoveryService.startScan();
+
+ // Then
+ verify(cloudApi).findDevices();
+ verify(discoveryListener).thingDiscovered(eq(discoveryService),
+ argThat(discoveryResult -> discoveryResult.getLabel().equals(device1.name())));
+ verify(discoveryListener).thingDiscovered(eq(discoveryService),
+ argThat(discoveryResult -> discoveryResult.getLabel().equals(device2.name())));
+ verify(discoveryListener, never()).thingDiscovered(eq(discoveryService),
+ argThat(discoveryResult -> discoveryResult.getLabel().equals(device3.name())));
+ verify(discoveryListener, never()).thingDiscovered(eq(discoveryService),
+ argThat(discoveryResult -> discoveryResult.getLabel().equals(device4.name())));
+ }
+
+ @Test
+ @DisplayName("Cloud API throws an exception during device retrieval, method logs the error")
+ void testLogsErrorWhenCloudApiThrowsException() throws Exception {
+ // Given
+ var cloudApi = mock(CloudApi.class);
+ var bridgeUid = mock(ThingUID.class);
+ var discoveryService = new SalusDiscovery(cloudApi, bridgeUid);
+
+ given(cloudApi.findDevices()).willThrow(new SalusApiException("API error"));
+
+ // When
+ discoveryService.startScan();
+
+ // Then
+ // no error is thrown, OK
+ }
+
+ private Device randomDevice(boolean connected) {
+ var random = new Random();
+ var map = new HashMap<@NotNull String, @Nullable Object>();
+ if (connected) {
+ map.put("connection_status", "online");
+ }
+ return new Device("dsn-" + random.nextInt(), "name-" + random.nextInt(), connected, map);
+ }
+}
// Given
var properties = new HashMap<String, @Nullable Object>();
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();
// Given
var properties = new HashMap<String, @Nullable Object>();
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();
public void testReturnsFalseIfConnectionStatusPropertyDoesNotExist() {
// Given
var properties = new HashMap<String, @Nullable Object>();
- 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();
public void testReturnsFalseIfPropertiesParameterDoesNotContainConnectionStatusKey() {
// Given
var properties = new HashMap<String, @Nullable Object>();
- 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();
// Given
var properties = new HashMap<String, @Nullable Object>();
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();
// Given
var properties = new HashMap<String, @Nullable Object>();
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();
Map<String, @Nullable Object> 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();
String name2 = "Device 2";
Map<String, @Nullable Object> 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);
String name = "Device";
Map<String, @Nullable Object> 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);
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<String, @Nullable Object> properties1 = Map.of("connection_status", "online");
Map<String, @Nullable Object> 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.
String name = "Device";
Map<String, @Nullable Object> 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();
*/
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;
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
// 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\"}}";
// Given
GsonMapper gsonMapper = GsonMapper.INSTANCE;
String json = "[{\"device\":{\"dsn\":\"123\",\"product_name\":\"Product 1\"}},{\"device\":{\"dsn\":\"456\",\"product_name\":\"Product 2\"}}]";
- List<Device> expectedDevices = List.of(new Device("123", "Product 1", Collections.emptyMap()),
- new Device("456", "Product 2", Collections.emptyMap()));
+ List<Device> expectedDevices = List.of(new Device("123", "Product 1", true, Collections.emptyMap()),
+ new Device("456", "Product 2", true, Collections.emptyMap()));
// When
List<Device> devices = gsonMapper.parseDevices(json);
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
+++ /dev/null
-/**
- * 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 static org.assertj.core.api.Assertions.*;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.ArgumentMatchers.endsWith;
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.when;
-
-import java.time.Clock;
-import java.util.ArrayList;
-import java.util.Optional;
-
-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;
-
-/**
- * @author Martin Grześlowski - Initial contribution
- */
-@SuppressWarnings("DataFlowIssue")
-@NonNullByDefault
-public class SalusApiTest {
-
- // Find devices returns sorted set of devices
- @Test
- @DisplayName("Find devices returns sorted set of devices")
- public void testFindDevicesReturnsSortedSetOfDevices() throws Exception {
- // Given
- var username = "correct_username";
- var password = "correct_password".toCharArray();
- 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");
- var response = "devices_json";
- when(restClient.get(anyString(), any())).thenReturn(response);
-
- var devices = new ArrayList<Device>();
- when(mapper.parseDevices(anyString())).thenReturn(devices);
-
- var salusApi = new SalusApi(username, password, baseUrl, restClient, mapper, clock);
- setAuthToken(salusApi, restClient, mapper, authToken);
-
- // When
- var result = salusApi.findDevices();
-
- // Then
- assertThat(result).containsExactlyInAnyOrderElementsOf(devices);
- }
-
- // Find device properties returns sorted set of device properties
- @Test
- @DisplayName("Find device properties returns sorted set of device properties")
- public void testFindDevicePropertiesReturnsSortedSetOfDeviceProperties() throws Exception {
- // Given
- var username = "correct_username";
- var password = "correct_password".toCharArray();
- 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");
- var response = "device_properties_json";
- when(restClient.get(anyString(), any())).thenReturn(response);
-
- var deviceProperties = new ArrayList<DeviceProperty<?>>();
- when(mapper.parseDeviceProperties(anyString())).thenReturn(deviceProperties);
-
- var salusApi = new SalusApi(username, password, baseUrl, restClient, mapper, clock);
- setAuthToken(salusApi, restClient, mapper, authToken);
-
- // When
- var result = salusApi.findDeviceProperties("dsn");
-
- // Then
- assertThat(result).containsExactlyInAnyOrderElementsOf(deviceProperties);
- }
-
- // Set value for property returns OK response with datapoint value
- @Test
- @DisplayName("Set value for property returns OK response with datapoint value")
- public void testSetValueForPropertyReturnsOkResponseWithDatapointValue() throws Exception {
- // Given
- var username = "correct_username";
- var password = "correct_password".toCharArray();
- 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");
- var response = "datapoint_value_json";
- when(restClient.post(anyString(), any(), any())).thenReturn(response);
-
- var datapointValue = new Object();
- when(mapper.datapointValue(anyString())).thenReturn(Optional.of(datapointValue));
-
- var salusApi = new SalusApi(username, password, baseUrl, restClient, mapper, clock);
- setAuthToken(salusApi, restClient, mapper, authToken);
-
- // When
- var result = salusApi.setValueForProperty("dsn", "property_name", "value");
-
- // Then
- assertThat(result).isEqualTo(datapointValue);
- }
-
- // Login with incorrect credentials throws HttpUnauthorizedException
- @Test
- @DisplayName("Login with incorrect credentials throws HttpUnauthorizedException")
- public void testLoginWithIncorrectCredentialsThrowsHttpUnauthorizedException() throws Exception {
- // Given
- var username = "incorrect_username";
- var password = "incorrect_password".toCharArray();
- var baseUrl = "https://example.com";
- var restClient = mock(RestClient.class);
- var mapper = mock(GsonMapper.class);
- var clock = Clock.systemDefaultZone();
-
- when(restClient.post(anyString(), any(), any()))
- .thenThrow(new HttpSalusApiException(401, "unauthorized_error_json"));
-
- var salusApi = new SalusApi(username, password, baseUrl, restClient, mapper, clock);
-
- // When
- ThrowingCallable findDevicesResponse = salusApi::findDevices;
-
- // Then
- assertThatThrownBy(findDevicesResponse).isInstanceOf(HttpSalusApiException.class)
- .hasMessage("HTTP Error 401: unauthorized_error_json");
- }
-
- // Find devices with invalid auth token throws HttpUnauthorizedException
- @Test
- @DisplayName("Find devices with invalid auth token throws HttpUnauthorizedException")
- public void testFindDevicesWithInvalidAuthTokenThrowsHttpUnauthorizedException() throws Exception {
- // Given
- var username = "correct_username";
- var password = "correct_password".toCharArray();
- 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.get(anyString(), any())).thenThrow(new HttpSalusApiException(401, "unauthorized_error_json"));
-
- var salusApi = new SalusApi(username, password, baseUrl, restClient, mapper, clock);
- setAuthToken(salusApi, restClient, mapper, authToken);
-
- // When
- ThrowingCallable objectApiResponse = salusApi::findDevices;
-
- // Then
- assertThatThrownBy(objectApiResponse).isInstanceOf(HttpSalusApiException.class)
- .hasMessage("HTTP Error 401: unauthorized_error_json");
- }
-
- // Find device properties with invalid auth token throws HttpUnauthorizedException
- @Test
- @DisplayName("Find device properties with invalid auth token throws HttpUnauthorizedException")
- public void testFindDevicePropertiesWithInvalidAuthTokenThrowsHttpUnauthorizedException() throws Exception {
- // Given
- var username = "correct_username";
- var password = "correct_password".toCharArray();
- 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.get(anyString(), any())).thenThrow(new HttpSalusApiException(401, "unauthorized_error_json"));
-
- var salusApi = new SalusApi(username, password, baseUrl, restClient, mapper, clock);
- setAuthToken(salusApi, restClient, mapper, authToken);
-
- // When
- ThrowingCallable objectApiResponse = () -> salusApi.findDeviceProperties("dsn");
-
- // Given
- assertThatThrownBy(objectApiResponse).isInstanceOf(HttpSalusApiException.class)
- .hasMessage("HTTP Error 401: unauthorized_error_json");
- }
-
- // Set value for property with invalid auth token throws HttpUnauthorizedException
- @Test
- @DisplayName("Set value for property with invalid auth token throws HttpUnauthorizedException")
- public void testSetValueForPropertyWithInvalidAuthTokenThrowsHttpUnauthorizedException() throws Exception {
- // Given
- var username = "correct_username";
- var password = "correct_password".toCharArray();
- 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);
-
- // When
- ThrowingCallable objectApiResponse = () -> salusApi.setValueForProperty("dsn", "property_name", "value");
-
- // given
-
- assertThatThrownBy(objectApiResponse).isInstanceOf(HttpSalusApiException.class)
- .hasMessage("HTTP Error 401: unauthorized_error_json");
- }
-
- // Find device properties with invalid DSN returns ApiResponse with error
- @Test
- @DisplayName("Find device properties with invalid DSN returns ApiResponse with error")
- public void testFindDevicePropertiesWithInvalidDsnReturnsApiResponseWithError() throws Exception {
- // Given
- var username = "correct_username";
- var password = "correct_password".toCharArray();
- 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.get(anyString(), any())).thenThrow(new HttpSalusApiException(404, "not found"));
-
- var salusApi = new SalusApi(username, password, baseUrl, restClient, mapper, clock);
- setAuthToken(salusApi, restClient, mapper, authToken);
-
- // When
- ThrowingCallable result = () -> salusApi.findDeviceProperties("invalid_dsn");
-
- // Then
- assertThatThrownBy(result).isInstanceOf(HttpSalusApiException.class).hasMessage("HTTP Error 404: not found");
- }
-
- // Login with incorrect credentials 3 times throws HttpForbiddenException
- @Test
- @DisplayName("Login with incorrect credentials 3 times throws HttpForbiddenException")
- public void testLoginWithIncorrectCredentials3TimesThrowsHttpForbiddenException() throws Exception {
- // Given
- var username = "incorrect_username";
- var password = "incorrect_password".toCharArray();
- var baseUrl = "https://example.com";
- var restClient = mock(RestClient.class);
- var mapper = mock(GsonMapper.class);
- var clock = Clock.systemDefaultZone();
-
- when(restClient.post(anyString(), any(), any()))
- .thenThrow(new HttpSalusApiException(403, "forbidden_error_json"));
-
- var salusApi = new SalusApi(username, password, baseUrl, restClient, mapper, clock);
-
- // When
- ThrowingCallable findDevicesResponse = salusApi::findDevices;
-
- // Then
- assertThatThrownBy(findDevicesResponse).isInstanceOf(HttpSalusApiException.class)
- .hasMessage("HTTP Error 403: forbidden_error_json");
- }
-
- private void setAuthToken(SalusApi salusApi, RestClient restClient, GsonMapper mapper, AuthToken authToken)
- throws SalusApiException {
- var username = "correct_username";
- var password = "correct_password".toCharArray();
- var inputBody = "login_param_json";
- when(mapper.loginParam(username, password)).thenReturn(inputBody);
- var authTokenJson = "auth_token";
- when(mapper.authToken(authTokenJson)).thenReturn(authToken);
-
- when(restClient.post(endsWith("/users/sign_in.json"), eq(new RestClient.Content(inputBody, "application/json")),
- any())).thenReturn(authTokenJson);
- }
-}