[salus] Add support for AWS (#16807)

* AWS support

Signed-off-by: Martin Grześlowski <martin.grzeslowski@gmail.com>
Signed-off-by: Ciprian Pascu <contact@ciprianpascu.ro>
This commit is contained in:
Martin 2024-06-02 12:46:58 +02:00 committed by Ciprian Pascu
parent 141a85e629
commit 685fa4bdc3
50 changed files with 2301 additions and 329 deletions

View File

@ -28,3 +28,8 @@ checker-qual
* License: GNU General Public License (GPL), version 2, with the classpath exception (https://checkerframework.org/manual/#license)
* Project: https://checkerframework.org/
* Source: https://github.com/typetools/checker-framework
aws-crt
* License: Apache License 2.0
* Project: https://github.com/awslabs/aws-crt-java
* Source: https://github.com/awslabs/aws-crt-java

View File

@ -10,6 +10,7 @@ extensive experience, we accurately identify user needs and introduce products t
- **`salus-cloud-bridge`**: This bridge connects to Salus Cloud. Multiple bridges are supported for those with multiple
accounts.
- **`salus-aws-bridge`**: This bridge connects to AWS Salus Cloud. Multiple bridges are supported for those with multiple accounts.
- **`salus-device`**: A generic Salus device that exposes all properties (as channels) from the Cloud without any
modifications.
- **`salus-it600-device`**: A temperature controller with extended capabilities.
@ -31,6 +32,21 @@ assumed automatically based on the `oem_model`.
| refreshInterval | integer (seconds) | Refresh time in seconds | 30 | no | yes |
| propertiesRefreshInterval | integer (seconds) | How long device properties should be cached | 5 | no | yes |
### `salus-aws-bridge` Thing Configuration
| Name | Type | Description | Default | Required | Advanced |
|---------------------------|-------------------|----------------------------------------------|----------------------------|----------|----------|
| username | text | Username/email to log in to Salus Cloud | N/A | yes | no |
| password | text | Password to log in to Salus Cloud | N/A | yes | no |
| url | text | URL to Salus Cloud | https://eu.salusconnect.io | no | yes |
| refreshInterval | integer (seconds) | Refresh time in seconds | 30 | no | yes |
| propertiesRefreshInterval | integer (seconds) | How long device properties should be cached | 5 | no | yes |
| userPoolId | text | | XGRz3CgoY | no | yes |
| clientId | text | The app client ID | 4pk5efh3v84g5dav43imsv4fbj | no | yes |
| region | text | Region with which the SDK should communicate | eu-central-1 | no | yes |
| companyCode | text | | salus-eu | no | yes |
| awsService | text | | a24u3z7zzwrtdl-ats | no | yes |
### `salus-device` and `salus-it600-device` Thing Configuration
| Name | Type | Description | Default | Required | Advanced |

View File

@ -34,6 +34,14 @@
<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>

View File

@ -0,0 +1,60 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.salus.internal;
import java.util.SortedSet;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.salus.internal.rest.Device;
import org.openhab.binding.salus.internal.rest.DeviceProperty;
import org.openhab.binding.salus.internal.rest.exceptions.AuthSalusApiException;
import org.openhab.binding.salus.internal.rest.exceptions.SalusApiException;
/**
* The SalusApi class is responsible for interacting with a REST API to perform various operations related to the Salus
* system. It handles authentication, token management, and provides methods to retrieve and manipulate device
* information and properties.
*
* @author Martin Grześlowski - Initial contribution
*/
@NonNullByDefault
public interface SalusApi {
/**
* Finds all available devices.
*
* @return A sorted set of Device objects representing the discovered devices.
* @throws SalusApiException if an error occurs during device discovery.
*/
SortedSet<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;
}

View File

@ -36,9 +36,10 @@ public class SalusBindingConstants {
public static final ThingTypeUID SALUS_DEVICE_TYPE = new ThingTypeUID(BINDING_ID, "salus-device");
public static final ThingTypeUID SALUS_IT600_DEVICE_TYPE = new ThingTypeUID(BINDING_ID, "salus-it600-device");
public static final ThingTypeUID SALUS_SERVER_TYPE = new ThingTypeUID(BINDING_ID, "salus-cloud-bridge");
public static final ThingTypeUID SALUS_AWS_TYPE = new ThingTypeUID(BINDING_ID, "salus-aws-bridge");
public static final Set<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";

View File

@ -12,17 +12,19 @@
*/
package org.openhab.binding.salus.internal;
import static org.openhab.binding.salus.internal.SalusBindingConstants.SALUS_DEVICE_TYPE;
import static org.openhab.binding.salus.internal.SalusBindingConstants.SALUS_IT600_DEVICE_TYPE;
import static org.openhab.binding.salus.internal.SalusBindingConstants.SALUS_SERVER_TYPE;
import static org.openhab.binding.salus.internal.SalusBindingConstants.SUPPORTED_THING_TYPES_UIDS;
import static org.openhab.binding.salus.internal.SalusBindingConstants.*;
import java.util.Collections;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.salus.internal.discovery.CloudDiscovery;
import org.openhab.binding.salus.internal.handler.CloudBridgeHandler;
import org.openhab.binding.salus.internal.aws.handler.AwsCloudBridgeHandler;
import org.openhab.binding.salus.internal.cloud.handler.CloudBridgeHandler;
import org.openhab.binding.salus.internal.discovery.SalusDiscovery;
import org.openhab.binding.salus.internal.handler.AbstractBridgeHandler;
import org.openhab.binding.salus.internal.handler.DeviceHandler;
import org.openhab.binding.salus.internal.handler.It600Handler;
import org.openhab.core.config.discovery.DiscoveryService;
@ -33,6 +35,7 @@ import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.thing.binding.ThingHandlerFactory;
import org.osgi.framework.ServiceRegistration;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
@ -51,6 +54,8 @@ public class SalusHandlerFactory extends BaseThingHandlerFactory {
private final Logger logger = LoggerFactory.getLogger(SalusHandlerFactory.class);
protected final @NonNullByDefault({}) HttpClientFactory httpClientFactory;
private final Map<ThingHandler, ServiceRegistration<?>> discoveryServices = Collections
.synchronizedMap(new HashMap<>());
@Activate
public SalusHandlerFactory(@Reference HttpClientFactory httpClientFactory) {
@ -75,10 +80,18 @@ public class SalusHandlerFactory extends BaseThingHandlerFactory {
if (SALUS_SERVER_TYPE.equals(thingTypeUID)) {
return newSalusCloudBridge(thing);
}
if (SALUS_AWS_TYPE.equals(thingTypeUID)) {
return newSalusAwsBridge(thing);
}
return null;
}
@Override
protected void removeHandler(ThingHandler thingHandler) {
unregisterThingDiscovery(thingHandler);
}
private ThingHandler newSalusDevice(Thing thing) {
logger.debug("New Salus Device {}", thing.getUID().getId());
return new DeviceHandler(thing);
@ -91,12 +104,28 @@ public class SalusHandlerFactory extends BaseThingHandlerFactory {
private ThingHandler newSalusCloudBridge(Thing thing) {
var handler = new CloudBridgeHandler((Bridge) thing, httpClientFactory);
var cloudDiscovery = new CloudDiscovery(handler, handler, handler.getThing().getUID());
registerThingDiscovery(cloudDiscovery);
registerThingDiscovery(handler);
return handler;
}
private synchronized void registerThingDiscovery(DiscoveryService discoveryService) {
bundleContext.registerService(DiscoveryService.class.getName(), discoveryService, new Hashtable<>());
private ThingHandler newSalusAwsBridge(Thing thing) {
var handler = new AwsCloudBridgeHandler((Bridge) thing, httpClientFactory);
registerThingDiscovery(handler);
return handler;
}
private synchronized void registerThingDiscovery(AbstractBridgeHandler<?> handler) {
var discoveryService = new SalusDiscovery(handler, handler.getThing().getUID());
var serviceRegistration = bundleContext.registerService(DiscoveryService.class.getName(), discoveryService,
new Hashtable<>());
discoveryServices.put(handler, serviceRegistration);
}
private synchronized void unregisterThingDiscovery(ThingHandler handler) {
if (!discoveryServices.containsKey(handler)) {
return;
}
var serviceRegistration = discoveryServices.get(handler);
serviceRegistration.unregister();
}
}

View File

@ -0,0 +1,120 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.salus.internal.aws.handler;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.salus.internal.handler.AbstractBridgeConfig;
/**
* @author Martin Grześlowski - Initial contribution
*/
@NonNullByDefault
public class AwsCloudBridgeConfig extends AbstractBridgeConfig {
private String userPoolId = "eu-central-1_XGRz3CgoY";
private String identityPoolId = "60912c00-287d-413b-a2c9-ece3ccef9230";
private String clientId = "4pk5efh3v84g5dav43imsv4fbj";
private String region = "eu-central-1";
private String companyCode = "salus-eu";
private String awsService = "a24u3z7zzwrtdl-ats";
public AwsCloudBridgeConfig() {
setUrl("https://service-api.eu.premium.salusconnect.io");
}
public AwsCloudBridgeConfig(String username, String password, String url, long refreshInterval,
long propertiesRefreshInterval, int maxHttpRetries, String userPoolId, String identityPoolId,
String clientId, String region, String companyCode, String awsService) {
super(username, password, url, refreshInterval, propertiesRefreshInterval, maxHttpRetries);
this.userPoolId = userPoolId;
this.identityPoolId = identityPoolId;
this.clientId = clientId;
this.region = region;
this.companyCode = companyCode;
this.awsService = awsService;
if (url.isBlank()) {
setUrl("https://service-api.eu.premium.salusconnect.io");
}
}
public String getUserPoolId() {
return userPoolId;
}
public void setUserPoolId(String userPoolId) {
this.userPoolId = userPoolId;
}
public String getIdentityPoolId() {
return identityPoolId;
}
public void setIdentityPoolId(String identityPoolId) {
this.identityPoolId = identityPoolId;
}
public String getClientId() {
return clientId;
}
public void setClientId(String clientId) {
this.clientId = clientId;
}
public String getRegion() {
return region;
}
public void setRegion(String region) {
this.region = region;
}
public String getCompanyCode() {
return companyCode;
}
public void setCompanyCode(String companyCode) {
this.companyCode = companyCode;
}
public String getAwsService() {
return awsService;
}
public void setAwsService(String awsService) {
this.awsService = awsService;
}
@Override
public boolean isValid() {
return super.isValid() && !userPoolId.isBlank() && !identityPoolId.isBlank() && !clientId.isBlank()
&& !region.isBlank() && !companyCode.isBlank() && !awsService.isBlank();
}
@Override
public String toString() {
return "AwsCloudBridgeConfig{" + //
"userPoolId='" + userPoolId + '\'' + //
"identityPoolId='" + identityPoolId + '\'' + //
", clientId='" + clientId + '\'' + //
", region='" + region + '\'' + //
", companyCode='" + companyCode + '\'' + //
", awsService='" + awsService + '\'' + //
", username='" + username + '\'' + //
", password='<SECRET>'" + //
", url='" + url + '\'' + //
", refreshInterval=" + refreshInterval + //
", propertiesRefreshInterval=" + propertiesRefreshInterval + //
", maxHttpRetries=" + maxHttpRetries + //
'}';
}
}

View File

@ -0,0 +1,62 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.salus.internal.aws.handler;
import static java.nio.charset.StandardCharsets.UTF_8;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.salus.internal.SalusApi;
import org.openhab.binding.salus.internal.aws.http.AwsSalusApi;
import org.openhab.binding.salus.internal.handler.AbstractBridgeHandler;
import org.openhab.binding.salus.internal.rest.GsonMapper;
import org.openhab.binding.salus.internal.rest.RestClient;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.thing.Bridge;
/**
* @author Martin Grześlowski - Initial contribution
*/
@NonNullByDefault
public final class AwsCloudBridgeHandler extends AbstractBridgeHandler<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";
}
}

View File

@ -0,0 +1,26 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.salus.internal.aws.http;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* @author Martin Grześlowski - Initial contribution
*/
@NonNullByDefault
record Authentication(String accessToken, int expiresIn, String tokenType, String refreshToken, String idToken) {
@Override
public String toString() {
return "Authentication{" + hashCode() + "}";
}
}

View File

@ -0,0 +1,418 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.salus.internal.aws.http;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Objects.requireNonNull;
import static org.eclipse.jetty.http.HttpMethod.POST;
import static org.openhab.binding.salus.internal.aws.http.CognitoGson.GSON;
import java.math.BigInteger;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Base64;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import javax.crypto.Mac;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.util.StringContentProvider;
import org.openhab.binding.salus.internal.rest.exceptions.AuthSalusApiException;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Copied from org.openhab.binding.windcentrale.internal.api.AuthenticationHelper
*
* @author Martin Grześlowski - Initial contribution
*/
@NonNullByDefault
class AuthenticationHelper {
private final Logger logger = LoggerFactory.getLogger(AuthenticationHelper.class);
private static final String SRP_N_HEX = "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" //
+ "29024E088A67CC74020BBEA63B139B22514A08798E3404DD" //
+ "EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245" //
+ "E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" //
+ "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D" //
+ "C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F" //
+ "83655D23DCA3AD961C62F356208552BB9ED529077096966D" //
+ "670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B" //
+ "E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9" //
+ "DE2BCBF6955817183995497CEA956AE515D2261898FA0510" //
+ "15728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64" //
+ "ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7" //
+ "ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6B" //
+ "F12FFA06D98A0864D87602733EC86A64521F2B18177B200C" //
+ "BBE117577A615D6C770988C0BAD946E208E24FA074E5AB31" //
+ "43DB5BFCE0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF";
private static final BigInteger SRP_A;
private static final BigInteger SRP_A2;
private static final BigInteger SRP_G = BigInteger.valueOf(2);
private static final BigInteger SRP_K;
private static final BigInteger SRP_N = new BigInteger(SRP_N_HEX, 16);
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter
.ofPattern("EEE MMM d HH:mm:ss z yyyy", Locale.US).withZone(ZoneId.of("UTC"));
private static final int DERIVED_KEY_SIZE = 16;
private static final int EPHEMERAL_KEY_LENGTH = 1024;
private static final String DERIVED_KEY_INFO = "Caldera Derived Key";
private static final Duration REQUEST_TIMEOUT = Duration.ofMinutes(1);
private static final String COGNITO_URL_FORMAT = "https://cognito-idp.%s.amazonaws.com/";
private static final String COGNITO_IDENTITY_URL_FORMAT = "https://cognito-identity.%s.amazonaws.com/";
private static final String INITIATE_AUTH_TARGET = "AWSCognitoIdentityProviderService.InitiateAuth";
private static final String RESPOND_TO_AUTH_TARGET = "AWSCognitoIdentityProviderService.RespondToAuthChallenge";
private static final String GET_ID = "AWSCognitoIdentityService.GetId";
private static final String GET_CREDENTIALS_FOR_IDENTITY = "AWSCognitoIdentityService.GetCredentialsForIdentity";
/**
* Internal class for doing the HKDF calculations.
*/
private static final class Hkdf {
private static final int MAX_KEY_SIZE = 255;
private final String algorithm;
private @Nullable SecretKey prk;
/**
* @param algorithm The type of HMAC algorithm to be used
*/
private Hkdf(String algorithm) {
if (!algorithm.startsWith("Hmac")) {
throw new IllegalArgumentException(
"Invalid algorithm " + algorithm + ". HKDF may only be used with HMAC algorithms.");
}
this.algorithm = algorithm;
}
/**
* @param ikm the input key material
* @param salt random bytes for salt
*/
private void init(byte[] ikm, byte[] salt) {
try {
Mac mac = Mac.getInstance(algorithm);
byte[] realSalt = salt.length == 0 ? new byte[mac.getMacLength()] : salt.clone();
mac.init(new SecretKeySpec(realSalt, algorithm));
SecretKeySpec key = new SecretKeySpec(mac.doFinal(ikm), algorithm);
unsafeInitWithoutKeyExtraction(key);
} catch (InvalidKeyException | NoSuchAlgorithmException e) {
throw new IllegalStateException("Failed to initialize HKDF", e);
}
}
/**
* @param rawKey current secret key
*/
private void unsafeInitWithoutKeyExtraction(SecretKey rawKey) {
if (!rawKey.getAlgorithm().equals(algorithm)) {
throw new IllegalArgumentException(
"Algorithm for the provided key must match the algorithm for this HKDF. Expected " + algorithm
+ " but found " + rawKey.getAlgorithm());
} else {
prk = rawKey;
}
}
private byte[] deriveKey(String info, int length) {
if (prk == null) {
throw new IllegalStateException("HKDF has not been initialized");
}
if (length < 0) {
throw new IllegalArgumentException("Length must be a non-negative value");
}
Mac mac = createMac();
if (length > MAX_KEY_SIZE * mac.getMacLength()) {
throw new IllegalArgumentException(
"Requested keys may not be longer than 255 times the underlying HMAC length");
}
byte[] result = new byte[length];
byte[] bytes = info.getBytes(UTF_8);
byte[] t = {};
int loc = 0;
for (byte i = 1; loc < length; ++i) {
mac.update(t);
mac.update(bytes);
mac.update(i);
t = mac.doFinal();
for (int x = 0; x < t.length && loc < length; ++loc) {
result[loc] = t[x];
++x;
}
}
return result;
}
/**
* @return the generated message authentication code
*/
private Mac createMac() {
try {
Mac mac = Mac.getInstance(algorithm);
mac.init(prk);
return mac;
} catch (InvalidKeyException | NoSuchAlgorithmException e) {
throw new IllegalStateException("Could not create MAC implementing algorithm: " + algorithm, e);
}
}
}
static {
// Initialize the SRP variables
try {
SecureRandom sr = SecureRandom.getInstance("SHA1PRNG");
MessageDigest md = MessageDigest.getInstance("SHA-256");
md.update(SRP_N.toByteArray());
byte[] digest = md.digest(SRP_G.toByteArray());
SRP_K = new BigInteger(1, digest);
BigInteger srpA;
BigInteger srpA2;
do {
srpA2 = new BigInteger(EPHEMERAL_KEY_LENGTH, sr).mod(SRP_N);
srpA = SRP_G.modPow(srpA2, SRP_N);
} while (srpA.mod(SRP_N).equals(BigInteger.ZERO));
SRP_A = srpA;
SRP_A2 = srpA2;
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SRP variables cannot be initialized due to missing algorithm", e);
}
}
private final HttpClient httpClient;
private final String userPoolId;
private final String clientId;
private final String region;
private final String identityPoolId;
public AuthenticationHelper(HttpClientFactory httpClientFactory, String userPoolId, String clientId, String region,
String identityPoolId) {
this.httpClient = httpClientFactory.getCommonHttpClient();
this.userPoolId = userPoolId;
this.clientId = clientId;
this.region = region;
this.identityPoolId = identityPoolId;
}
/**
* Method to orchestrate the SRP Authentication.
*
* @param username username for the SRP request
* @param password password for the SRP request
* @return JWT token if the request is successful
* @throws AuthSalusApiException when SRP authentication fails
*/
public AuthenticationResultResponse performSrpAuthentication(String username, String password)
throws AuthSalusApiException {
InitiateAuthRequest initiateAuthRequest = InitiateAuthRequest.userSrpAuth(clientId, username,
SRP_A.toString(16));
try {
ChallengeResponse challengeResponse = postInitiateAuthSrp(initiateAuthRequest);
if ("PASSWORD_VERIFIER".equals(challengeResponse.challengeName)) {
RespondToAuthChallengeRequest challengeRequest = createRespondToAuthChallengeRequest(challengeResponse,
password);
return postRespondToAuthChallenge(challengeRequest);
} else {
throw new AuthSalusApiException(
"Unsupported authentication challenge: " + challengeResponse.challengeName);
}
} catch (IllegalStateException | InvalidKeyException | NoSuchAlgorithmException e) {
throw new AuthSalusApiException("SRP Authentication failed", e);
}
}
public AuthenticationResultResponse performTokenRefresh(String refreshToken) throws AuthSalusApiException {
InitiateAuthRequest initiateAuthRequest = InitiateAuthRequest.refreshTokenAuth(clientId, refreshToken);
try {
return postInitiateAuthRefresh(initiateAuthRequest);
} catch (IllegalStateException e) {
throw new AuthSalusApiException("Token refresh failed", e);
}
}
/**
* Creates a response request to the SRP authentication challenge from the user pool.
*
* @param challengeResponse authentication challenge returned from the Cognito user pool
* @param password password to be used to respond to the authentication challenge
* @return request created for the previous authentication challenge
*/
private RespondToAuthChallengeRequest createRespondToAuthChallengeRequest(ChallengeResponse challengeResponse,
String password) throws InvalidKeyException, NoSuchAlgorithmException {
String salt = challengeResponse.getSalt();
String secretBlock = challengeResponse.getSecretBlock();
String userIdForSrp = challengeResponse.getUserIdForSrp();
String usernameInternal = challengeResponse.getUsername();
if (secretBlock.isEmpty() || userIdForSrp.isEmpty() || usernameInternal.isEmpty()) {
throw new IllegalArgumentException("Required authentication response challenge parameters are null");
}
BigInteger srpB = new BigInteger(challengeResponse.getSrpB(), 16);
if (srpB.mod(SRP_N).equals(BigInteger.ZERO)) {
throw new IllegalStateException("SRP error, B cannot be zero");
}
String timestamp = DATE_TIME_FORMATTER.format(Instant.now());
byte[] key = getPasswordAuthenticationKey(userIdForSrp, password, srpB, new BigInteger(salt, 16));
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
mac.update(userPoolId.split("_", 2)[1].getBytes(UTF_8));
mac.update(userIdForSrp.getBytes(UTF_8));
mac.update(Base64.getDecoder().decode(secretBlock));
byte[] hmac = mac.doFinal(timestamp.getBytes(UTF_8));
String signature = new String(Base64.getEncoder().encode(hmac), UTF_8);
return new RespondToAuthChallengeRequest(clientId, usernameInternal, secretBlock, signature, timestamp);
}
private byte[] getPasswordAuthenticationKey(String userId, String userPassword, BigInteger srpB, BigInteger salt) {
try {
// Authenticate the password
// srpU = H(SRP_A, srpB)
MessageDigest md = MessageDigest.getInstance("SHA-256");
md.update(SRP_A.toByteArray());
BigInteger srpU = new BigInteger(1, md.digest(srpB.toByteArray()));
if (srpU.equals(BigInteger.ZERO)) {
throw new IllegalStateException("Hash of A and B cannot be zero");
}
// srpX = H(salt | H(poolName | userId | ":" | password))
md.reset();
md.update(userPoolId.split("_", 2)[1].getBytes(UTF_8));
md.update(userId.getBytes(UTF_8));
md.update(":".getBytes(UTF_8));
byte[] userIdHash = md.digest(userPassword.getBytes(UTF_8));
md.reset();
md.update(salt.toByteArray());
BigInteger srpX = new BigInteger(1, md.digest(userIdHash));
BigInteger srpS = (srpB.subtract(SRP_K.multiply(SRP_G.modPow(srpX, SRP_N)))
.modPow(SRP_A2.add(srpU.multiply(srpX)), SRP_N)).mod(SRP_N);
Hkdf hkdf = new Hkdf("HmacSHA256");
hkdf.init(srpS.toByteArray(), srpU.toByteArray());
return hkdf.deriveKey(DERIVED_KEY_INFO, DERIVED_KEY_SIZE);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e.getMessage(), e);
}
}
private ChallengeResponse postInitiateAuthSrp(InitiateAuthRequest request) throws AuthSalusApiException {
String responseContent = postJson(INITIATE_AUTH_TARGET, GSON.toJson(request));
return requireNonNull(GSON.fromJson(responseContent, ChallengeResponse.class));
}
private AuthenticationResultResponse postInitiateAuthRefresh(InitiateAuthRequest request)
throws AuthSalusApiException {
String responseContent = postJson(INITIATE_AUTH_TARGET, GSON.toJson(request));
return requireNonNull(GSON.fromJson(responseContent, AuthenticationResultResponse.class));
}
private AuthenticationResultResponse postRespondToAuthChallenge(RespondToAuthChallengeRequest request)
throws AuthSalusApiException {
String responseContent = postJson(RESPOND_TO_AUTH_TARGET, GSON.toJson(request));
return requireNonNull(GSON.fromJson(responseContent, AuthenticationResultResponse.class));
}
private String postJson(String target, String requestContent) throws AuthSalusApiException {
return postJson(target, requestContent, String.format(COGNITO_URL_FORMAT, region));
}
private String postJson(String target, String requestContent, String url) throws AuthSalusApiException {
try {
logger.debug("Posting JSON to: {}", url);
ContentResponse contentResponse = httpClient.newRequest(url) //
.method(POST) //
.header("x-amz-target", target) //
.content(new StringContentProvider(requestContent), "application/x-amz-json-1.1") //
.timeout(REQUEST_TIMEOUT.toNanos(), TimeUnit.NANOSECONDS).send();
String response = contentResponse.getContentAsString();
if (contentResponse.getStatus() >= 400) {
logger.debug("Cognito API error: {}", response);
CognitoError error = GSON.fromJson(response, CognitoError.class);
String message;
if (error != null && !error.message.isBlank()) {
message = String.format("Cognito API error: %s (%s)", error.message, error.type);
} else {
message = String.format("Cognito API error: %s (HTTP %s)", contentResponse.getReason(),
contentResponse.getStatus());
}
throw new AuthSalusApiException(message);
} else {
logger.trace("Response: {}", response);
}
return response;
} catch (InterruptedException | TimeoutException | ExecutionException e) {
throw new AuthSalusApiException("Cognito API request failed: " + e.getMessage(), e);
}
}
public GetIdResponse getId(AuthenticationResultResponse result) throws AuthSalusApiException {
var request = Map.of(//
"IdentityPoolId", "%s:%s".formatted(region, identityPoolId), //
"Logins", Map.of(//
"cognito-idp.%s.amazonaws.com/%s".formatted(region, userPoolId), //
result.getIdToken())//
);
var json = postJson(GET_ID, GSON.toJson(request), COGNITO_IDENTITY_URL_FORMAT.formatted(region));
return requireNonNull(GSON.fromJson(json, GetIdResponse.class));
}
public GetCredentialsForIdentityResponse getCredentialsForIdentity(AuthenticationResultResponse accessToken,
String identityId) throws AuthSalusApiException {
var request = Map.of(//
"IdentityId", identityId, //
"Logins", Map.of(//
"cognito-idp.%s.amazonaws.com/%s".formatted(region, userPoolId), //
accessToken.getIdToken())//
);
var json = postJson(GET_CREDENTIALS_FOR_IDENTITY, GSON.toJson(request),
COGNITO_IDENTITY_URL_FORMAT.formatted(region));
return requireNonNull(GSON.fromJson(json, GetCredentialsForIdentityResponse.class));
}
}

View File

@ -0,0 +1,54 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.salus.internal.aws.http;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Copied from org.openhab.binding.windcentrale.internal.dto.AuthenticationResultResponse
*
* @author Martin Grześlowski - Initial contribution
*/
@NonNullByDefault
class AuthenticationResultResponse {
private static class AuthenticationResult {
public String accessToken = "";
public int expiresIn;
public String idToken = "";
public String refreshToken = "";
public String tokenType = "";
}
private AuthenticationResult authenticationResult = new AuthenticationResult();
public String getAccessToken() {
return authenticationResult.accessToken;
}
public int getExpiresIn() {
return authenticationResult.expiresIn;
}
public String getIdToken() {
return authenticationResult.idToken;
}
public String getRefreshToken() {
return authenticationResult.refreshToken;
}
public String getTokenType() {
return authenticationResult.tokenType;
}
}

View File

@ -0,0 +1,188 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.salus.internal.aws.http;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.time.ZoneOffset.UTC;
import static java.util.Objects.requireNonNull;
import java.time.Clock;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.SortedSet;
import java.util.TreeSet;
import java.util.concurrent.ExecutionException;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.salus.internal.rest.AbstractSalusApi;
import org.openhab.binding.salus.internal.rest.Device;
import org.openhab.binding.salus.internal.rest.DeviceProperty;
import org.openhab.binding.salus.internal.rest.GsonMapper;
import org.openhab.binding.salus.internal.rest.RestClient;
import org.openhab.binding.salus.internal.rest.exceptions.AuthSalusApiException;
import org.openhab.binding.salus.internal.rest.exceptions.SalusApiException;
import org.openhab.binding.salus.internal.rest.exceptions.UnsuportedSalusApiException;
import org.openhab.core.io.net.http.HttpClientFactory;
import software.amazon.awssdk.crt.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.crt.auth.signing.AwsSigner;
import software.amazon.awssdk.crt.auth.signing.AwsSigningConfig;
import software.amazon.awssdk.crt.auth.signing.AwsSigningResult;
import software.amazon.awssdk.crt.http.HttpHeader;
import software.amazon.awssdk.crt.http.HttpRequest;
/**
* The SalusApi class is responsible for interacting with a REST API to perform various operations related to the Salus
* system. It handles authentication, token management, and provides methods to retrieve and manipulate device
* information and properties.
*
* @author Martin Grześlowski - Initial contribution
*/
@NonNullByDefault
public class AwsSalusApi extends AbstractSalusApi<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");
}
}

View File

@ -0,0 +1,54 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.salus.internal.aws.http;
import java.util.Map;
import java.util.Objects;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Copied from org.openhab.binding.windcentrale.internal.dto.ChallengeResponse
*
* @author Martin Grześlowski - Initial contribution
*/
@NonNullByDefault
class ChallengeResponse {
public String challengeName = "";
public Map<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");
}
}

View File

@ -0,0 +1,26 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.salus.internal.aws.http;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* @author Martin Grześlowski - Initial contribution
*/
@NonNullByDefault
public record CogitoCredentials(String accessKeyId, String secretKey, String sessionToken) {
@Override
public String toString() {
return "CogitoCredentials{" + hashCode() + "}";
}
}

View File

@ -0,0 +1,32 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.salus.internal.aws.http;
import org.eclipse.jdt.annotation.NonNullByDefault;
import com.google.gson.annotations.SerializedName;
/**
* Copied from org.openhab.binding.windcentrale.internal.dto.CognitoError
*
* @author Martin Grześlowski - Initial contribution
*/
@NonNullByDefault
class CognitoError {
@SerializedName("__type")
public String type = "";
@SerializedName("message")
public String message = "";
}

View File

@ -0,0 +1,35 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.salus.internal.aws.http;
import java.time.Instant;
import org.eclipse.jdt.annotation.NonNullByDefault;
import com.google.gson.FieldNamingPolicy;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
/**
* Copied from org.openhab.binding.windcentrale.internal.dto.CognitoGson
*
* @author Martin Grześlowski - Initial contribution
*/
@NonNullByDefault
class CognitoGson {
public static final Gson GSON = new GsonBuilder()//
.setFieldNamingPolicy(FieldNamingPolicy.UPPER_CAMEL_CASE)//
.registerTypeAdapter(Instant.class, new InstantDeserializer())//
.create();
}

View File

@ -0,0 +1,100 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.salus.internal.aws.http;
import java.time.Instant;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* @author Martin Grześlowski - Initial contribution
*/
@NonNullByDefault
class GetCredentialsForIdentityResponse {
private String identityId = "";
private Credentials credentials = new Credentials();
@NonNullByDefault
static class Credentials {
private String accessKeyId = "";
private String secretKey = "";
private String sessionToken = "";
private Instant expiration = Instant.now();
public String getAccessKeyId() {
return accessKeyId;
}
public void setAccessKeyId(String accessKeyId) {
this.accessKeyId = accessKeyId;
}
public String getSecretKey() {
return secretKey;
}
public void setSecretKey(String secretKey) {
this.secretKey = secretKey;
}
public String getSessionToken() {
return sessionToken;
}
public void setSessionToken(String sessionToken) {
this.sessionToken = sessionToken;
}
public Instant getExpiration() {
return expiration;
}
public void setExpiration(Instant expiration) {
this.expiration = expiration;
}
@Override
public String toString() {
return "Credentials{" + //
"accessKeyId='" + accessKeyId.hashCode() + '\'' + //
", secretKey='" + secretKey.hashCode() + '\'' + //
", sessionToken='" + sessionToken.hashCode() + '\'' + //
", expiration=" + expiration + //
'}';
}
}
String getIdentityId() {
return identityId;
}
void setIdentityId(String identityId) {
this.identityId = identityId;
}
Credentials getCredentials() {
return credentials;
}
void setCredentials(Credentials credentials) {
this.credentials = credentials;
}
@Override
public String toString() {
return "GetCredentialsForIdentityResponse{" + //
"identityId='" + identityId + '\'' + //
", credentials=" + credentials + //
'}';
}
}

View File

@ -0,0 +1,38 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.salus.internal.aws.http;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* @author Martin Grześlowski - Initial contribution
*/
@NonNullByDefault
class GetIdResponse {
private String identityId = "";
public String getIdentityId() {
return identityId;
}
public void setIdentityId(String identityId) {
this.identityId = identityId;
}
@Override
public String toString() {
return "GetIdResponse{" + //
"identityId='" + identityId + '\'' + //
'}';
}
}

View File

@ -0,0 +1,47 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.salus.internal.aws.http;
import java.util.Map;
import java.util.TreeMap;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Copied from org.openhab.binding.windcentrale.internal.dto.InitiateAuthRequest
*
* @author Martin Grześlowski - Initial contribution
*/
@NonNullByDefault
class InitiateAuthRequest {
public String authFlow = "";
public String clientId = "";
public Map<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));
}
}

View File

@ -0,0 +1,36 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.salus.internal.aws.http;
import java.lang.reflect.Type;
import java.time.Instant;
import org.eclipse.jdt.annotation.NonNullByDefault;
import com.google.gson.JsonDeserializationContext;
import com.google.gson.JsonDeserializer;
import com.google.gson.JsonElement;
import com.google.gson.JsonParseException;
/**
* @author Martin Grześlowski - Initial contribution
*/
@NonNullByDefault
class InstantDeserializer implements JsonDeserializer<@org.eclipse.jdt.annotation.Nullable Instant> {
@Override
@org.eclipse.jdt.annotation.Nullable
public Instant deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
throws JsonParseException {
return Instant.ofEpochSecond(json.getAsLong());
}
}

View File

@ -0,0 +1,40 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.salus.internal.aws.http;
import java.util.LinkedHashMap;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Copied from org.openhab.binding.windcentrale.internal.dto.RespondToAuthChallengeRequest
*
* @author Martin Grześlowski - Initial contribution
*/
@NonNullByDefault
class RespondToAuthChallengeRequest {
public String challengeName = "PASSWORD_VERIFIER";
public String clientId = "";
public Map<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);
}
}

View File

@ -0,0 +1,39 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.salus.internal.cloud.handler;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.salus.internal.handler.AbstractBridgeConfig;
/**
* @author Martin Grześlowski - Initial contribution
*/
@NonNullByDefault
public class CloudBridgeConfig extends AbstractBridgeConfig {
public CloudBridgeConfig() {
super();
}
public CloudBridgeConfig(String username, String password, String url, long refreshInterval,
long propertiesRefreshInterval, int maxHttpRetries) {
super(username, password, url, refreshInterval, propertiesRefreshInterval, maxHttpRetries);
}
@Override
public String toString() {
return "CloudBridgeConfig{" + "username='" + username + '\'' + ", password='<SECRET>'" + ", url='" + url + '\''
+ ", refreshInterval=" + refreshInterval + ", propertiesRefreshInterval=" + propertiesRefreshInterval
+ '}';
}
}

View File

@ -0,0 +1,60 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.salus.internal.cloud.handler;
import static java.nio.charset.StandardCharsets.UTF_8;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.salus.internal.SalusApi;
import org.openhab.binding.salus.internal.cloud.rest.HttpSalusApi;
import org.openhab.binding.salus.internal.handler.AbstractBridgeHandler;
import org.openhab.binding.salus.internal.handler.CloudApi;
import org.openhab.binding.salus.internal.rest.GsonMapper;
import org.openhab.binding.salus.internal.rest.RestClient;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.thing.Bridge;
/**
* @author Martin Grześlowski - Initial contribution
*/
@NonNullByDefault
public final class CloudBridgeHandler extends AbstractBridgeHandler<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";
}
}

View File

@ -10,7 +10,7 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.salus.internal.rest;
package org.openhab.binding.salus.internal.cloud.rest;
import java.util.Objects;

View File

@ -10,19 +10,27 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.salus.internal.rest;
package org.openhab.binding.salus.internal.cloud.rest;
import static java.time.ZoneOffset.UTC;
import static java.util.Objects.requireNonNull;
import java.time.Clock;
import java.time.LocalDateTime;
import java.time.ZonedDateTime;
import java.util.SortedSet;
import java.util.TreeSet;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.openhab.binding.salus.internal.rest.AbstractSalusApi;
import org.openhab.binding.salus.internal.rest.Device;
import org.openhab.binding.salus.internal.rest.DeviceProperty;
import org.openhab.binding.salus.internal.rest.GsonMapper;
import org.openhab.binding.salus.internal.rest.RestClient;
import org.openhab.binding.salus.internal.rest.exceptions.AuthSalusApiException;
import org.openhab.binding.salus.internal.rest.exceptions.HttpSalusApiException;
import org.openhab.binding.salus.internal.rest.exceptions.SalusApiException;
/**
* The SalusApi class is responsible for interacting with a REST API to perform various operations related to the Salus
@ -32,47 +40,43 @@ import org.slf4j.LoggerFactory;
* @author Martin Grześlowski - Initial contribution
*/
@NonNullByDefault
public class SalusApi {
public class HttpSalusApi extends AbstractSalusApi<AuthToken> {
private static final int MAX_RETRIES = 3;
private static final long TOKEN_EXPIRE_TIME_ADJUSTMENT_SECONDS = 3;
private final Logger logger;
private final String username;
private final char[] password;
private final String baseUrl;
private final RestClient restClient;
private final GsonMapper mapper;
@Nullable
private AuthToken authToken;
@Nullable
private LocalDateTime authTokenExpireTime;
private final Clock clock;
public SalusApi(String username, char[] password, String baseUrl, RestClient restClient, GsonMapper mapper,
public HttpSalusApi(String username, byte[] password, String baseUrl, RestClient restClient, GsonMapper mapper,
Clock clock) {
this.username = username;
this.password = password;
this.baseUrl = removeTrailingSlash(baseUrl);
this.restClient = restClient;
this.mapper = mapper;
this.clock = clock;
// thanks to this, logger will always inform for which rest client it's doing the job
// it's helpful when more than one SalusApi exists
logger = LoggerFactory.getLogger(SalusApi.class.getName() + "[" + username.replace(".", "_") + "]");
super(username, password, baseUrl, restClient, mapper, clock);
}
public SalusApi(String username, char[] password, String baseUrl, RestClient restClient, GsonMapper mapper) {
this(username, password, baseUrl, restClient, mapper, Clock.systemDefaultZone());
public HttpSalusApi(String username, byte[] password, String baseUrl, RestClient restClient, GsonMapper mapper) {
super(username, password, baseUrl, restClient, mapper, Clock.systemDefaultZone());
}
private @Nullable String get(String url, RestClient.Header header, int retryAttempt) throws SalusApiException {
@Override
protected @Nullable String get(String url, RestClient.Header... headers)
throws SalusApiException, AuthSalusApiException {
return this.get(url, 1, headers);
}
@Override
protected @Nullable String post(String url, RestClient.Content content, RestClient.Header... headers)
throws SalusApiException, AuthSalusApiException {
return this.post(url, content, 1, headers);
}
private @Nullable String get(String url, int retryAttempt, RestClient.Header... headers)
throws SalusApiException, AuthSalusApiException {
refreshAccessToken();
try {
return restClient.get(url, authHeader());
return restClient.get(url, headers);
} catch (HttpSalusApiException ex) {
if (ex.getCode() == 401) {
if (retryAttempt <= MAX_RETRIES) {
forceRefreshAccessToken();
return get(url, header, retryAttempt + 1);
return get(url, retryAttempt + 1, headers);
}
logger.debug("Could not refresh access token after {} retries", MAX_RETRIES);
}
@ -80,16 +84,16 @@ public class SalusApi {
}
}
private @Nullable String post(String url, RestClient.Content content, RestClient.Header header, int retryAttempt)
throws SalusApiException {
private @Nullable String post(String url, RestClient.Content content, int retryAttempt,
RestClient.Header... headers) throws SalusApiException, AuthSalusApiException {
refreshAccessToken();
try {
return restClient.post(url, content, header);
return restClient.post(url, content, headers);
} catch (HttpSalusApiException ex) {
if (ex.getCode() == 401) {
if (retryAttempt <= MAX_RETRIES) {
forceRefreshAccessToken();
return post(url, content, header, retryAttempt + 1);
return post(url, content, retryAttempt + 1, headers);
}
logger.debug("Could not refresh access token after {} retries", MAX_RETRIES);
}
@ -97,18 +101,12 @@ public class SalusApi {
}
}
private static String removeTrailingSlash(String str) {
if (str.endsWith("/")) {
return str.substring(0, str.length() - 1);
}
return str;
@Override
protected void login() throws SalusApiException {
login(1);
}
private void login(String username, char[] password) throws SalusApiException {
login(username, password, 1);
}
private void login(String username, char[] password, int retryAttempt) throws SalusApiException {
private void login(int retryAttempt) throws SalusApiException {
logger.debug("Login with username '{}', retryAttempt={}", username, retryAttempt);
authToken = null;
authTokenExpireTime = null;
@ -121,16 +119,17 @@ public class SalusApi {
throw new HttpSalusApiException(401, "No response token from server");
}
var token = authToken = mapper.authToken(response);
authTokenExpireTime = LocalDateTime.now(clock).plusSeconds(token.expiresIn())
var local = LocalDateTime.now(clock).plusSeconds(token.expiresIn())
// this is to account that there is a delay between server setting `expires_in`
// and client (OpenHAB) receiving it
.minusSeconds(TOKEN_EXPIRE_TIME_ADJUSTMENT_SECONDS);
authTokenExpireTime = ZonedDateTime.of(local, UTC);
logger.debug("Correctly logged in for user {}, role={}, expires at {} ({} secs)", username, token.role(),
authTokenExpireTime, token.expiresIn());
} catch (HttpSalusApiException ex) {
if (ex.getCode() == 401 || ex.getCode() == 403) {
if (retryAttempt < MAX_RETRIES) {
login(username, password, retryAttempt + 1);
login(retryAttempt + 1);
}
throw ex;
}
@ -138,38 +137,16 @@ public class SalusApi {
}
}
private void forceRefreshAccessToken() throws SalusApiException {
private void forceRefreshAccessToken() throws AuthSalusApiException {
logger.debug("Force refresh access token");
authToken = null;
authTokenExpireTime = null;
cleanAuth();
refreshAccessToken();
}
private void refreshAccessToken() throws SalusApiException {
if (this.authToken == null || isExpiredToken()) {
try {
login(username, password);
} catch (SalusApiException ex) {
logger.warn("Accesstoken could not be acquired, for user '{}', response={}", username, ex.getMessage());
this.authToken = null;
this.authTokenExpireTime = null;
throw ex;
}
}
}
private boolean isExpiredToken() {
var expireTime = authTokenExpireTime;
return expireTime == null || LocalDateTime.now(clock).isAfter(expireTime);
}
private String url(String url) {
return baseUrl + url;
}
public SortedSet<Device> findDevices() throws SalusApiException {
@Override
public SortedSet<Device> findDevices() throws SalusApiException, AuthSalusApiException {
refreshAccessToken();
var response = get(url("/apiv1/devices.json"), authHeader(), 1);
var response = get(url("/apiv1/devices.json"), authHeader());
return new TreeSet<>(mapper.parseDevices(requireNonNull(response)));
}
@ -177,20 +154,24 @@ public class SalusApi {
return new RestClient.Header("Authorization", "auth_token " + requireNonNull(authToken).accessToken());
}
public SortedSet<DeviceProperty<?>> findDeviceProperties(String dsn) throws SalusApiException {
@Override
public SortedSet<DeviceProperty<?>> findDeviceProperties(String dsn)
throws SalusApiException, AuthSalusApiException {
refreshAccessToken();
var response = get(url("/apiv1/dsns/" + dsn + "/properties.json"), authHeader(), 1);
var response = get(url("/apiv1/dsns/" + dsn + "/properties.json"), authHeader());
if (response == null) {
throw new SalusApiException("No device properties for device %s".formatted(dsn));
}
return new TreeSet<>(mapper.parseDeviceProperties(response));
}
public Object setValueForProperty(String dsn, String propertyName, Object value) throws SalusApiException {
@Override
public Object setValueForProperty(String dsn, String propertyName, Object value)
throws SalusApiException, AuthSalusApiException {
refreshAccessToken();
var finalUrl = url("/apiv1/dsns/" + dsn + "/properties/" + propertyName + "/datapoints.json");
var json = mapper.datapointParam(value);
var response = post(finalUrl, new RestClient.Content(json), authHeader(), 1);
var response = post(finalUrl, new RestClient.Content(json), 1, authHeader());
var datapointValue = mapper.datapointValue(response);
return datapointValue.orElseThrow(() -> new HttpSalusApiException(404, "No datapoint in return"));
}

View File

@ -12,21 +12,17 @@
*/
package org.openhab.binding.salus.internal.discovery;
import static org.openhab.binding.salus.internal.SalusBindingConstants.SALUS_DEVICE_TYPE;
import static org.openhab.binding.salus.internal.SalusBindingConstants.SALUS_IT600_DEVICE_TYPE;
import static org.openhab.binding.salus.internal.SalusBindingConstants.SUPPORTED_THING_TYPES_UIDS;
import static org.openhab.binding.salus.internal.SalusBindingConstants.SalusDevice.DSN;
import static org.openhab.binding.salus.internal.SalusBindingConstants.SalusDevice.IT_600;
import static org.openhab.binding.salus.internal.SalusBindingConstants.SalusDevice.OEM_MODEL;
import static org.openhab.binding.salus.internal.SalusBindingConstants.*;
import static org.openhab.binding.salus.internal.SalusBindingConstants.SalusDevice.*;
import java.util.Locale;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.salus.internal.handler.CloudApi;
import org.openhab.binding.salus.internal.handler.CloudBridgeHandler;
import org.openhab.binding.salus.internal.rest.Device;
import org.openhab.binding.salus.internal.rest.SalusApiException;
import org.openhab.binding.salus.internal.rest.exceptions.AuthSalusApiException;
import org.openhab.binding.salus.internal.rest.exceptions.SalusApiException;
import org.openhab.core.config.discovery.AbstractDiscoveryService;
import org.openhab.core.config.discovery.DiscoveryResult;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
@ -39,13 +35,12 @@ import org.slf4j.LoggerFactory;
* @author Martin Grześlowski - Initial contribution
*/
@NonNullByDefault
public class CloudDiscovery extends AbstractDiscoveryService {
private final Logger logger = LoggerFactory.getLogger(CloudDiscovery.class);
public class SalusDiscovery extends AbstractDiscoveryService {
private final Logger logger = LoggerFactory.getLogger(SalusDiscovery.class);
private final CloudApi cloudApi;
private final ThingUID bridgeUid;
public CloudDiscovery(CloudBridgeHandler bridgeHandler, CloudApi cloudApi, ThingUID bridgeUid)
throws IllegalArgumentException {
public SalusDiscovery(CloudApi cloudApi, ThingUID bridgeUid) throws IllegalArgumentException {
super(SUPPORTED_THING_TYPES_UIDS, 10, true);
this.cloudApi = cloudApi;
this.bridgeUid = bridgeUid;
@ -56,8 +51,8 @@ public class CloudDiscovery extends AbstractDiscoveryService {
try {
var devices = cloudApi.findDevices();
logger.debug("Found {} devices while scanning", devices.size());
devices.stream().filter(Device::isConnected).forEach(this::addThing);
} catch (SalusApiException e) {
devices.stream().filter(Device::connected).forEach(this::addThing);
} catch (SalusApiException | AuthSalusApiException e) {
logger.warn("Error while scanning", e);
stopScan();
}
@ -71,6 +66,7 @@ public class CloudDiscovery extends AbstractDiscoveryService {
}
private static ThingTypeUID findDeviceType(Device device) {
// cloud device
var props = device.properties();
if (props.containsKey(OEM_MODEL)) {
var model = props.get(OEM_MODEL);
@ -80,6 +76,10 @@ public class CloudDiscovery extends AbstractDiscoveryService {
}
}
}
// aws device
if (device.dsn().contains(IT_600)) {
return SALUS_IT600_DEVICE_TYPE;
}
return SALUS_DEVICE_TYPE;
}

View File

@ -20,74 +20,24 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
* @author Martin Grześlowski - Initial contribution
*/
@NonNullByDefault
public class CloudBridgeConfig {
private String username = "";
private String password = "";
private String url = "";
private long refreshInterval = 30;
private long propertiesRefreshInterval = 5;
private int maxHttpRetries = 3;
public abstract class AbstractBridgeConfig {
protected String username = "";
protected String password = "";
protected String url = "";
protected long refreshInterval = 30;
protected long propertiesRefreshInterval = 5;
protected int maxHttpRetries = 3;
public CloudBridgeConfig() {
public AbstractBridgeConfig() {
}
public CloudBridgeConfig(String username, String password, String url, long refreshInterval,
long propertiesRefreshInterval) {
public AbstractBridgeConfig(String username, String password, String url, long refreshInterval,
long propertiesRefreshInterval, int maxHttpRetries) {
this.username = username;
this.password = password;
this.url = url;
this.refreshInterval = refreshInterval;
this.propertiesRefreshInterval = propertiesRefreshInterval;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getUrl() {
if (url.isBlank()) {
return DEFAULT_URL;
}
return url;
}
public void setUrl(String url) {
this.url = url;
}
public long getRefreshInterval() {
return refreshInterval;
}
public void setRefreshInterval(long refreshInterval) {
this.refreshInterval = refreshInterval;
}
public long getPropertiesRefreshInterval() {
return propertiesRefreshInterval;
}
public void setPropertiesRefreshInterval(long propertiesRefreshInterval) {
this.propertiesRefreshInterval = propertiesRefreshInterval;
}
public int getMaxHttpRetries() {
return maxHttpRetries;
}
public void setMaxHttpRetries(int maxHttpRetries) {
this.maxHttpRetries = maxHttpRetries;
}
@ -95,10 +45,54 @@ public class CloudBridgeConfig {
return !username.isBlank() && !password.isBlank();
}
@Override
public String toString() {
return "CloudBridgeConfig{" + "username='" + username + '\'' + ", password='<SECRET>'" + ", url='" + url + '\''
+ ", refreshInterval=" + refreshInterval + ", propertiesRefreshInterval=" + propertiesRefreshInterval
+ '}';
public final String getUsername() {
return username;
}
public final void setUsername(String username) {
this.username = username;
}
public final String getPassword() {
return password;
}
public final void setPassword(String password) {
this.password = password;
}
public final String getUrl() {
if (url.isBlank()) {
return DEFAULT_URL;
}
return url;
}
public final void setUrl(String url) {
this.url = url;
}
public final long getRefreshInterval() {
return refreshInterval;
}
public final void setRefreshInterval(long refreshInterval) {
this.refreshInterval = refreshInterval;
}
public final long getPropertiesRefreshInterval() {
return propertiesRefreshInterval;
}
public final void setPropertiesRefreshInterval(long propertiesRefreshInterval) {
this.propertiesRefreshInterval = propertiesRefreshInterval;
}
public final int getMaxHttpRetries() {
return maxHttpRetries;
}
public final void setMaxHttpRetries(int maxHttpRetries) {
this.maxHttpRetries = maxHttpRetries;
}
}

View File

@ -22,12 +22,14 @@ import static org.openhab.core.types.RefreshType.REFRESH;
import java.time.Duration;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.SortedSet;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.salus.internal.SalusApi;
import org.openhab.binding.salus.internal.SalusBindingConstants;
import org.openhab.binding.salus.internal.rest.Device;
import org.openhab.binding.salus.internal.rest.DeviceProperty;
@ -35,8 +37,8 @@ import org.openhab.binding.salus.internal.rest.GsonMapper;
import org.openhab.binding.salus.internal.rest.HttpClient;
import org.openhab.binding.salus.internal.rest.RestClient;
import org.openhab.binding.salus.internal.rest.RetryHttpClient;
import org.openhab.binding.salus.internal.rest.SalusApi;
import org.openhab.binding.salus.internal.rest.SalusApiException;
import org.openhab.binding.salus.internal.rest.exceptions.AuthSalusApiException;
import org.openhab.binding.salus.internal.rest.exceptions.SalusApiException;
import org.openhab.core.common.ThreadPoolManager;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.thing.Bridge;
@ -55,9 +57,11 @@ import com.github.benmanes.caffeine.cache.LoadingCache;
* @author Martin Grześlowski - Initial contribution
*/
@NonNullByDefault
public final class CloudBridgeHandler extends BaseBridgeHandler implements CloudApi {
private Logger logger = LoggerFactory.getLogger(CloudBridgeHandler.class.getName());
public abstract class AbstractBridgeHandler<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
@ -65,14 +69,15 @@ public final class CloudBridgeHandler extends BaseBridgeHandler implements Cloud
@Nullable
private ScheduledFuture<?> scheduledFuture;
public CloudBridgeHandler(Bridge bridge, HttpClientFactory httpClientFactory) {
public AbstractBridgeHandler(Bridge bridge, HttpClientFactory httpClientFactory, Class<ConfigT> configClass) {
super(bridge);
this.httpClientFactory = httpClientFactory;
this.configClass = configClass;
}
@Override
public void initialize() {
CloudBridgeConfig config = this.getConfigAs(CloudBridgeConfig.class);
var config = this.getConfigAs(configClass);
if (!config.isValid()) {
updateStatus(OFFLINE, CONFIGURATION_ERROR, "@text/cloud-bridge-handler.initialize.username-pass-not-valid");
return;
@ -81,11 +86,9 @@ public final class CloudBridgeHandler extends BaseBridgeHandler implements Cloud
if (config.getMaxHttpRetries() > 0) {
httpClient = new RetryHttpClient(httpClient, config.getMaxHttpRetries());
}
@Nullable
SalusApi localSalusApi = salusApi = new SalusApi(config.getUsername(), config.getPassword().toCharArray(),
config.getUrl(), httpClient, GsonMapper.INSTANCE);
var localSalusApi = salusApi = newSalusApi(config, httpClient, GsonMapper.INSTANCE);
logger = LoggerFactory
.getLogger(CloudBridgeHandler.class.getName() + "[" + config.getUsername().replace(".", "_") + "]");
.getLogger(this.getClass().getName() + "[" + config.getUsername().replace(".", "_") + "]");
ScheduledExecutorService scheduledPool = ThreadPoolManager.getScheduledPool(SalusBindingConstants.BINDING_ID);
scheduledPool.schedule(() -> tryConnectToCloud(localSalusApi), 1, MICROSECONDS);
@ -101,6 +104,8 @@ public final class CloudBridgeHandler extends BaseBridgeHandler implements Cloud
// check *tryConnectToCloud(SalusApi)*
}
protected abstract SalusApi newSalusApi(ConfigT config, RestClient httpClient, GsonMapper gsonMapper);
private void tryConnectToCloud(SalusApi localSalusApi) {
try {
localSalusApi.findDevices();
@ -109,6 +114,9 @@ public final class CloudBridgeHandler extends BaseBridgeHandler implements Cloud
} catch (SalusApiException ex) {
updateStatus(OFFLINE, COMMUNICATION_ERROR,
"@text/cloud-bridge-handler.initialize.cannot-connect-to-cloud [\"" + ex.getMessage() + "\"]");
} catch (AuthSalusApiException ex) {
updateStatus(OFFLINE, COMMUNICATION_ERROR,
"@text/cloud-bridge-handler.initialize.auth-exception [\"" + ex.getMessage() + "\"]");
}
}
@ -158,13 +166,15 @@ public final class CloudBridgeHandler extends BaseBridgeHandler implements Cloud
}
@Override
public SortedSet<DeviceProperty<?>> findPropertiesForDevice(String dsn) throws SalusApiException {
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 {
public boolean setValueForProperty(String dsn, String propertyName, Object value)
throws SalusApiException, AuthSalusApiException {
try {
@Nullable
SalusApi api = requireNonNull(salusApi);
@ -205,19 +215,23 @@ public final class CloudBridgeHandler extends BaseBridgeHandler implements Cloud
"Cannot set value {} ({}) for property {} ({}) on device {} because value class does not match property class",
setValue, setValue.getClass().getSimpleName(), propertyName, prop.getClass().getSimpleName(), dsn);
return false;
} catch (SalusApiException ex) {
} catch (AuthSalusApiException | SalusApiException ex) {
devicePropertiesCache.invalidateAll();
throw ex;
}
}
@Override
public SortedSet<Device> findDevices() throws SalusApiException {
public SortedSet<Device> findDevices() throws SalusApiException, AuthSalusApiException {
return requireNonNull(this.salusApi).findDevices();
}
@Override
public Optional<Device> findDevice(String dsn) throws SalusApiException {
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();
}

View File

@ -18,7 +18,8 @@ import java.util.SortedSet;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.salus.internal.rest.Device;
import org.openhab.binding.salus.internal.rest.DeviceProperty;
import org.openhab.binding.salus.internal.rest.SalusApiException;
import org.openhab.binding.salus.internal.rest.exceptions.AuthSalusApiException;
import org.openhab.binding.salus.internal.rest.exceptions.SalusApiException;
/**
* @author Martin Grześlowski - Initial contribution
@ -30,7 +31,7 @@ public interface CloudApi {
*
* @return all devices from cloud
*/
SortedSet<Device> findDevices() throws SalusApiException;
SortedSet<Device> findDevices() throws SalusApiException, AuthSalusApiException;
/**
* Find a device by DSN
@ -38,7 +39,7 @@ public interface CloudApi {
* @param dsn of the device to find
* @return a device with given DSN (or empty if no found)
*/
Optional<Device> findDevice(String dsn) throws SalusApiException;
Optional<Device> findDevice(String dsn) throws SalusApiException, AuthSalusApiException;
/**
* Sets value for a property
@ -48,7 +49,8 @@ public interface CloudApi {
* @param value value to set
* @return if value was properly set
*/
boolean setValueForProperty(String dsn, String propertyName, Object value) throws SalusApiException;
boolean setValueForProperty(String dsn, String propertyName, Object value)
throws SalusApiException, AuthSalusApiException;
/**
* Finds all properties for a device
@ -56,5 +58,7 @@ public interface CloudApi {
* @param dsn of the device
* @return all properties of the device
*/
SortedSet<DeviceProperty<?>> findPropertiesForDevice(String dsn) throws SalusApiException;
SortedSet<DeviceProperty<?>> findPropertiesForDevice(String dsn) throws SalusApiException, AuthSalusApiException;
boolean isReadOnly();
}

View File

@ -33,7 +33,8 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.salus.internal.SalusBindingConstants;
import org.openhab.binding.salus.internal.rest.DeviceProperty;
import org.openhab.binding.salus.internal.rest.SalusApiException;
import org.openhab.binding.salus.internal.rest.exceptions.AuthSalusApiException;
import org.openhab.binding.salus.internal.rest.exceptions.SalusApiException;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.OpenClosedType;
@ -79,7 +80,7 @@ public class DeviceHandler extends BaseThingHandler {
return;
}
var bridgeHandler = bridge.getHandler();
if (!(bridgeHandler instanceof CloudBridgeHandler cloudHandler)) {
if (!(bridgeHandler instanceof AbstractBridgeHandler<?> cloudHandler)) {
updateStatus(OFFLINE, BRIDGE_UNINITIALIZED, "@text/device-handler.initialize.errors.bridge-wrong-type");
return;
}
@ -100,7 +101,7 @@ public class DeviceHandler extends BaseThingHandler {
"@text/device-handler.initialize.errors.dsn-not-found [\"" + dsn + "\"]");
return;
}
if (!device.get().isConnected()) {
if (!device.get().connected()) {
updateStatus(OFFLINE, COMMUNICATION_ERROR,
"@text/device-handler.initialize.errors.dsn-not-connected [\"" + dsn + "\"]");
return;
@ -207,6 +208,9 @@ public class DeviceHandler extends BaseThingHandler {
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
if (command != REFRESH && cloudApi.isReadOnly()) {
return;
}
try {
if (command instanceof RefreshType) {
handleRefreshCommand(channelUID);
@ -226,13 +230,13 @@ public class DeviceHandler extends BaseThingHandler {
logger.warn("Does not know how to handle command `{}` ({}) on channel `{}`!", command,
command.getClass().getSimpleName(), channelUID);
}
} catch (SalusApiException e) {
} catch (AuthSalusApiException | SalusApiException e) {
logger.debug("Error while handling command `{}` on channel `{}`", command, channelUID, e);
updateStatus(OFFLINE, COMMUNICATION_ERROR, e.getLocalizedMessage());
}
}
private void handleRefreshCommand(ChannelUID channelUID) throws SalusApiException {
private void handleRefreshCommand(ChannelUID channelUID) throws SalusApiException, AuthSalusApiException {
var id = channelUID.getId();
String salusId;
boolean isX100;
@ -281,11 +285,12 @@ public class DeviceHandler extends BaseThingHandler {
updateState(channelUID, state);
}
private SortedSet<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)) {
@ -298,7 +303,8 @@ public class DeviceHandler extends BaseThingHandler {
handleCommand(channelUID, REFRESH);
}
private void handleDecimalCommand(ChannelUID channelUID, @Nullable DecimalType command) throws SalusApiException {
private void handleDecimalCommand(ChannelUID channelUID, @Nullable DecimalType command)
throws SalusApiException, AuthSalusApiException {
if (command == null) {
return;
}
@ -319,7 +325,8 @@ public class DeviceHandler extends BaseThingHandler {
handleCommand(channelUID, REFRESH);
}
private void handleStringCommand(ChannelUID channelUID, StringType command) throws SalusApiException {
private void handleStringCommand(ChannelUID channelUID, StringType command)
throws SalusApiException, AuthSalusApiException {
var id = channelUID.getId();
String salusId;
if (channelUidMap.containsKey(id)) {

View File

@ -14,31 +14,25 @@ package org.openhab.binding.salus.internal.handler;
import static java.math.RoundingMode.HALF_EVEN;
import static java.util.Objects.requireNonNull;
import static org.openhab.binding.salus.internal.SalusBindingConstants.Channels.It600.EXPECTED_TEMPERATURE;
import static org.openhab.binding.salus.internal.SalusBindingConstants.Channels.It600.TEMPERATURE;
import static org.openhab.binding.salus.internal.SalusBindingConstants.Channels.It600.WORK_TYPE;
import static org.openhab.binding.salus.internal.SalusBindingConstants.It600Device.HoldType.AUTO;
import static org.openhab.binding.salus.internal.SalusBindingConstants.It600Device.HoldType.MANUAL;
import static org.openhab.binding.salus.internal.SalusBindingConstants.It600Device.HoldType.OFF;
import static org.openhab.binding.salus.internal.SalusBindingConstants.It600Device.HoldType.TEMPORARY_MANUAL;
import static org.openhab.binding.salus.internal.SalusBindingConstants.Channels.It600.*;
import static org.openhab.binding.salus.internal.SalusBindingConstants.It600Device.HoldType.*;
import static org.openhab.binding.salus.internal.SalusBindingConstants.SalusDevice.DSN;
import static org.openhab.core.library.unit.SIUnits.CELSIUS;
import static org.openhab.core.thing.ThingStatus.OFFLINE;
import static org.openhab.core.thing.ThingStatus.ONLINE;
import static org.openhab.core.thing.ThingStatusDetail.BRIDGE_UNINITIALIZED;
import static org.openhab.core.thing.ThingStatusDetail.COMMUNICATION_ERROR;
import static org.openhab.core.thing.ThingStatusDetail.CONFIGURATION_ERROR;
import static org.openhab.core.thing.ThingStatusDetail.*;
import static org.openhab.core.types.RefreshType.REFRESH;
import java.math.BigDecimal;
import java.math.MathContext;
import java.util.ArrayList;
import java.util.Optional;
import java.util.Set;
import java.util.SortedSet;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.salus.internal.rest.DeviceProperty;
import org.openhab.binding.salus.internal.rest.SalusApiException;
import org.openhab.binding.salus.internal.rest.exceptions.AuthSalusApiException;
import org.openhab.binding.salus.internal.rest.exceptions.SalusApiException;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.types.StringType;
@ -56,14 +50,12 @@ import org.slf4j.LoggerFactory;
@NonNullByDefault
public class It600Handler extends BaseThingHandler {
private static final BigDecimal ONE_HUNDRED = new BigDecimal(100);
private static final Set<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);
@ -72,17 +64,20 @@ public class It600Handler extends BaseThingHandler {
@Override
public void initialize() {
AbstractBridgeHandler<?> abstractBridgeHandler;
{
var bridge = getBridge();
if (bridge == null) {
updateStatus(OFFLINE, BRIDGE_UNINITIALIZED, "@text/it600-handler.initialize.errors.no-bridge");
return;
}
if (!(bridge.getHandler() instanceof CloudBridgeHandler cloudHandler)) {
if (!(bridge.getHandler() instanceof AbstractBridgeHandler<?> cloudHandler)) {
updateStatus(OFFLINE, BRIDGE_UNINITIALIZED, "@text/it600-handler.initialize.errors.bridge-wrong-type");
return;
}
this.cloudApi = cloudHandler;
abstractBridgeHandler = cloudHandler;
channelPrefix = abstractBridgeHandler.channelPrefix();
}
dsn = (String) getConfig().get(DSN);
@ -102,7 +97,7 @@ public class It600Handler extends BaseThingHandler {
return;
}
// device is not connected
if (!device.get().isConnected()) {
if (!device.get().connected()) {
updateStatus(OFFLINE, COMMUNICATION_ERROR,
"@text/it600-handler.initialize.errors.dsn-not-connected [\"" + dsn + "\"]");
return;
@ -110,7 +105,7 @@ public class It600Handler extends BaseThingHandler {
// device is missing properties
try {
var deviceProperties = findDeviceProperties().stream().map(DeviceProperty::getName).toList();
var result = new ArrayList<>(REQUIRED_CHANNELS);
var result = new ArrayList<>(abstractBridgeHandler.it600RequiredChannels());
result.removeAll(deviceProperties);
if (!result.isEmpty()) {
updateStatus(OFFLINE, CONFIGURATION_ERROR,
@ -133,6 +128,9 @@ public class It600Handler extends BaseThingHandler {
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
if (command != REFRESH && cloudApi.isReadOnly()) {
return;
}
try {
var id = channelUID.getId();
switch (id) {
@ -148,19 +146,20 @@ public class It600Handler extends BaseThingHandler {
default:
logger.warn("Unknown channel `{}` for command `{}`", id, command);
}
} catch (SalusApiException e) {
} catch (SalusApiException | AuthSalusApiException e) {
logger.debug("Error while handling command `{}` on channel `{}`", command, channelUID, e);
updateStatus(OFFLINE, COMMUNICATION_ERROR, e.getLocalizedMessage());
}
}
private void handleCommandForTemperature(ChannelUID channelUID, Command command) throws SalusApiException {
private void handleCommandForTemperature(ChannelUID channelUID, Command command)
throws SalusApiException, AuthSalusApiException {
if (!(command instanceof RefreshType)) {
// only refresh commands are supported for temp channel
return;
}
findLongProperty("ep_9:sIT600TH:LocalTemperature_x100", "LocalTemperature_x100")
findLongProperty(channelPrefix + ":sIT600TH:LocalTemperature_x100", "LocalTemperature_x100")
.map(DeviceProperty.LongDeviceProperty::getValue).map(BigDecimal::new)
.map(value -> value.divide(ONE_HUNDRED, new MathContext(5, HALF_EVEN))).map(DecimalType::new)
.ifPresent(state -> {
@ -169,9 +168,10 @@ public class It600Handler extends BaseThingHandler {
});
}
private void handleCommandForExpectedTemperature(ChannelUID channelUID, Command command) throws SalusApiException {
private void handleCommandForExpectedTemperature(ChannelUID channelUID, Command command)
throws SalusApiException, AuthSalusApiException {
if (command instanceof RefreshType) {
findLongProperty("ep_9:sIT600TH:HeatingSetpoint_x100", "HeatingSetpoint_x100")
findLongProperty(channelPrefix + ":sIT600TH:HeatingSetpoint_x100", "HeatingSetpoint_x100")
.map(DeviceProperty.LongDeviceProperty::getValue).map(BigDecimal::new)
.map(value -> value.divide(ONE_HUNDRED, new MathContext(5, HALF_EVEN))).map(DecimalType::new)
.ifPresent(state -> {
@ -190,15 +190,17 @@ public class It600Handler extends BaseThingHandler {
if (rawValue != null) {
var value = rawValue.multiply(ONE_HUNDRED).longValue();
var property = findLongProperty("ep_9:sIT600TH:SetHeatingSetpoint_x100", "SetHeatingSetpoint_x100");
var property = findLongProperty(channelPrefix + ":sIT600TH:SetHeatingSetpoint_x100",
"SetHeatingSetpoint_x100");
if (property.isEmpty()) {
return;
}
var wasSet = cloudApi.setValueForProperty(dsn, property.get().getName(), value);
if (wasSet) {
findLongProperty("ep_9:sIT600TH:HeatingSetpoint_x100", "HeatingSetpoint_x100")
findLongProperty(channelPrefix + ":sIT600TH:HeatingSetpoint_x100", "HeatingSetpoint_x100")
.ifPresent(prop -> prop.setValue(value));
findLongProperty("ep_9:sIT600TH:HoldType", "HoldType").ifPresent(prop -> prop.setValue((long) MANUAL));
findLongProperty(channelPrefix + ":sIT600TH:HoldType", "HoldType")
.ifPresent(prop -> prop.setValue((long) MANUAL));
updateStatus(ONLINE);
}
return;
@ -208,10 +210,11 @@ public class It600Handler extends BaseThingHandler {
command.getClass().getSimpleName(), channelUID);
}
private void handleCommandForWorkType(ChannelUID channelUID, Command command) throws SalusApiException {
private void handleCommandForWorkType(ChannelUID channelUID, Command command)
throws SalusApiException, AuthSalusApiException {
if (command instanceof RefreshType) {
findLongProperty("ep_9:sIT600TH:HoldType", "HoldType").map(DeviceProperty.LongDeviceProperty::getValue)
.map(value -> switch (value.intValue()) {
findLongProperty(channelPrefix + ":sIT600TH:HoldType", "HoldType")
.map(DeviceProperty.LongDeviceProperty::getValue).map(value -> switch (value.intValue()) {
case AUTO -> "AUTO";
case MANUAL -> "MANUAL";
case TEMPORARY_MANUAL -> "TEMPORARY_MANUAL";
@ -241,7 +244,7 @@ public class It600Handler extends BaseThingHandler {
logger.warn("Unknown value `{}` for property HoldType!", typedCommand);
return;
}
var property = findLongProperty("ep_9:sIT600TH:SetHoldType", "SetHoldType");
var property = findLongProperty(channelPrefix + ":sIT600TH:SetHoldType", "SetHoldType");
if (property.isEmpty()) {
return;
}
@ -255,7 +258,7 @@ public class It600Handler extends BaseThingHandler {
}
private Optional<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)
@ -271,7 +274,7 @@ public class It600Handler extends BaseThingHandler {
return property;
}
private SortedSet<DeviceProperty<?>> findDeviceProperties() throws SalusApiException {
private SortedSet<DeviceProperty<?>> findDeviceProperties() throws SalusApiException, AuthSalusApiException {
return this.cloudApi.findPropertiesForDevice(dsn);
}
}

View File

@ -0,0 +1,108 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.salus.internal.rest;
import java.time.Clock;
import java.time.ZonedDateTime;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.salus.internal.SalusApi;
import org.openhab.binding.salus.internal.rest.exceptions.AuthSalusApiException;
import org.openhab.binding.salus.internal.rest.exceptions.SalusApiException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* @author Martin Grześlowski - Initial contribution
*/
@NonNullByDefault
public abstract class AbstractSalusApi<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;
}
}

View File

@ -23,12 +23,15 @@ import org.eclipse.jdt.annotation.Nullable;
/**
* @author Martin Grześlowski - Initial contribution
*/
public record Device(@NotNull String dsn, @NotNull String name,
public record Device(@NotNull String dsn, @NotNull String name, boolean connected,
@NotNull Map<@NotNull String, @Nullable Object> properties) implements Comparable<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
@ -36,14 +39,6 @@ public record Device(@NotNull String dsn, @NotNull String name,
return dsn.compareTo(o.dsn);
}
public boolean isConnected() {
if (properties.containsKey("connection_status")) {
var connectionStatus = properties.get("connection_status");
return connectionStatus != null && "online".equalsIgnoreCase(connectionStatus.toString());
}
return false;
}
@Override
public boolean equals(Object o) {
if (this == o) {

View File

@ -18,8 +18,10 @@ import static java.lang.String.format;
import static java.util.Collections.unmodifiableSortedMap;
import static java.util.Objects.requireNonNull;
import static java.util.Optional.empty;
import static java.util.stream.Collectors.toMap;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
@ -31,9 +33,9 @@ import java.util.stream.Collectors;
import javax.validation.constraints.NotNull;
import org.checkerframework.checker.units.qual.K;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.salus.internal.cloud.rest.AuthToken;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -45,7 +47,7 @@ import com.google.gson.reflect.TypeToken;
* The GsonMapper class is responsible for mapping JSON data to Java objects using the Gson library. It provides methods
* for converting JSON strings to various types of objects, such as authentication tokens, devices, device properties,
* and error messages.
*
*
* @author Martin Grześlowski - Initial contribution
*/
@NonNullByDefault
@ -58,7 +60,7 @@ public class GsonMapper {
};
private final Gson gson = new Gson();
public String loginParam(String username, char[] password) {
public String loginParam(String username, byte[] password) {
return gson.toJson(Map.of("user", Map.of("email", username, "password", new String(password))));
}
@ -71,6 +73,38 @@ public class GsonMapper {
.filter(Optional::isPresent).map(Optional::get).toList();
}
@SuppressWarnings("unchecked")
public List<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);
@ -132,7 +166,38 @@ public class GsonMapper {
}
properties = Collections.unmodifiableMap(properties);
return Optional.of(new Device(dsn.trim(), name.trim(), properties));
return Optional.of(new Device(dsn.trim(), name.trim(), isConnected(properties), properties));
}
public boolean isConnected(Map<@NotNull String, @Nullable Object> properties) {
if (properties.containsKey("connection_status")) {
var connectionStatus = properties.get("connection_status");
return connectionStatus != null && "online".equalsIgnoreCase(connectionStatus.toString());
}
return false;
}
private Optional<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")
@ -161,6 +226,61 @@ public class GsonMapper {
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);
@ -228,6 +348,17 @@ public class GsonMapper {
displayName, properties));
}
private DeviceProperty<?> parseAwsDeviceProperty(String key, Object value) {
if (value instanceof Boolean booleanValue) {
return new DeviceProperty.BooleanDeviceProperty(key, true, null, null, null, null, booleanValue, null);
}
if (value instanceof Number numberValue) {
return new DeviceProperty.LongDeviceProperty(key, true, null, null, null, null, numberValue.longValue(),
null);
}
return new DeviceProperty.StringDeviceProperty(key, true, null, null, null, null, value.toString(), null);
}
private DeviceProperty<?> buildDeviceProperty(String name, @Nullable String baseType, @Nullable Object value,
@Nullable Boolean readOnly, @Nullable String direction, @Nullable String dataUpdatedAt,
@Nullable String productName, @Nullable String displayName,
@ -329,6 +460,41 @@ public class GsonMapper {
return Optional.ofNullable(datapoint.get("value"));
}
public List<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) {
}
}

View File

@ -23,6 +23,8 @@ import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpResponseException;
import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.client.util.StringContentProvider;
import org.openhab.binding.salus.internal.rest.exceptions.HttpSalusApiException;
import org.openhab.binding.salus.internal.rest.exceptions.SalusApiException;
/**
* @author Martin Grześlowski - Initial contribution

View File

@ -16,6 +16,7 @@ import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.salus.internal.rest.exceptions.SalusApiException;
/**
* @author Martin Grześlowski - Initial contribution

View File

@ -14,6 +14,7 @@ package org.openhab.binding.salus.internal.rest;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.salus.internal.rest.exceptions.SalusApiException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -41,7 +42,7 @@ public class RetryHttpClient implements RestClient {
return restClient.get(url, headers);
} catch (SalusApiException ex) {
if (i < maxRetries - 1) {
logger.debug("Error while calling GET {}. Retrying {}/{}...", i + 1, maxRetries, url, ex);
logger.debug("Error while calling GET {}. Retrying {}/{}...", url, i + 1, maxRetries, ex);
} else {
throw ex;
}
@ -57,7 +58,7 @@ public class RetryHttpClient implements RestClient {
return restClient.post(url, content, headers);
} catch (SalusApiException ex) {
if (i < maxRetries - 1) {
logger.debug("Error while calling POST {}. Retrying {}/{}...", i + 1, maxRetries, url, ex);
logger.debug("Error while calling POST {}. Retrying {}/{}...", url, i + 1, maxRetries, ex);
} else {
throw ex;
}

View File

@ -0,0 +1,34 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.salus.internal.rest.exceptions;
import java.io.Serial;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* @author Martin Grześlowski - Initial contribution
*/
@NonNullByDefault
public class AuthSalusApiException extends Exception {
@Serial
private static final long serialVersionUID = 1L;
public AuthSalusApiException(String msg, Exception e) {
super(msg, e);
}
public AuthSalusApiException(String msg) {
super(msg);
}
}

View File

@ -10,7 +10,7 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.salus.internal.rest;
package org.openhab.binding.salus.internal.rest.exceptions;
import java.io.Serial;
@ -21,7 +21,6 @@ import org.eclipse.jetty.client.HttpResponseException;
* @author Martin Grześlowski - Initial contribution
*/
@NonNullByDefault
@SuppressWarnings("SerializableHasSerializationMethods")
public class HttpSalusApiException extends SalusApiException {
@Serial
private static final long serialVersionUID = 1L;

View File

@ -10,14 +10,16 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.salus.internal.rest;
package org.openhab.binding.salus.internal.rest.exceptions;
import java.io.Serial;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* @author Martin Grześlowski - Initial contribution
*/
@SuppressWarnings("SerializableHasSerializationMethods")
@NonNullByDefault
public class SalusApiException extends Exception {
@Serial
private static final long serialVersionUID = 1L;

View File

@ -0,0 +1,30 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.salus.internal.rest.exceptions;
import java.io.Serial;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* @author Martin Grześlowski - Initial contribution
*/
@NonNullByDefault
public class UnsuportedSalusApiException extends SalusApiException {
@Serial
private static final long serialVersionUID = 1L;
public UnsuportedSalusApiException(String msg) {
super(msg);
}
}

View File

@ -5,15 +5,38 @@ addon.salus.description = This is the binding for Salus, a renowned manufacturer
# thing types
thing-type.salus.salus-aws-bridge.label = AWS Salus Cloud
thing-type.salus.salus-aws-bridge.description = This bridge serves as a critical connection point to the Salus cloud. It's absolutely necessary for the integration of other Salus devices into the ecosystem, as it provides a pathway for them to interact with the Salus cloud. Without this bridge, the devices would be unable to send, receive or exchange data with the cloud platform, hindering functionality and data utilization.
thing-type.salus.salus-cloud-bridge.label = Salus Cloud
thing-type.salus.salus-cloud-bridge.description = This bridge serves as a critical connection point to the Salus cloud. It's absolutely necessary for the integration of other Salus devices into the ecosystem, as it provides a pathway for them to interact with the Salus cloud. Without this bridge, the devices would be unable to send, receive or exchange data with the cloud platform, hindering functionality and data utilization.
thing-type.salus.salus-device.label = Salus Binding Thing
thing-type.salus.salus-device.label = Salus Device
thing-type.salus.salus-device.description = This is a device type that represents a generic 'thing' for the Salus Binding, working in conjunction with the Salus cloud bridge. Channels will be discovered and established at runtime. The 'dsn' (ID in Salus cloud system) is a mandatory configuration parameter.
thing-type.salus.salus-it600-device.label = IT600 Salus Thermostat
thing-type.salus.salus-it600-device.description = The IT600 Salus Thermostat Device is a component utilized within the Salus IT600 thermostat system. This device communicates with the Salus cloud bridge and offers features including reading the current temperature, setting the desired temperature, and defining the operation type. The operation of this device depends on a unique Data Source Name (DSN) which serves as an identifier in the Salus cloud system.
# thing types config
thing-type.config.salus.salus-aws-bridge.awsService.label = AWS Service
thing-type.config.salus.salus-aws-bridge.clientId.label = Client ID
thing-type.config.salus.salus-aws-bridge.clientId.description = The app client ID
thing-type.config.salus.salus-aws-bridge.companyCode.label = Company Code
thing-type.config.salus.salus-aws-bridge.group.aws.label = AWS
thing-type.config.salus.salus-aws-bridge.group.aws.description = AWS Properties
thing-type.config.salus.salus-aws-bridge.maxHttpRetries.label = Max HTTP Retries
thing-type.config.salus.salus-aws-bridge.maxHttpRetries.description = How many times HTTP requests can be retried
thing-type.config.salus.salus-aws-bridge.password.label = Password
thing-type.config.salus.salus-aws-bridge.password.description = The password for your Salus account. This is used in conjunction with the username or email for authentication purposes.
thing-type.config.salus.salus-aws-bridge.propertiesRefreshInterval.label = Device Property Cache Expiration
thing-type.config.salus.salus-aws-bridge.propertiesRefreshInterval.description = The period (in seconds) after which the cached device properties will be discarded and re-fetched fresh from the Salus cloud.
thing-type.config.salus.salus-aws-bridge.refreshInterval.label = Refresh Interval
thing-type.config.salus.salus-aws-bridge.refreshInterval.description = The interval in seconds at which the connection to the Salus cloud should be refreshed to ensure up-to-date data.
thing-type.config.salus.salus-aws-bridge.region.label = Region
thing-type.config.salus.salus-aws-bridge.region.description = Region with which the SDK should communicate
thing-type.config.salus.salus-aws-bridge.url.label = Salus API URL
thing-type.config.salus.salus-aws-bridge.url.description = The base URL for the Salus cloud. Typically, this should remain as the default, unless directed to change by Salus.
thing-type.config.salus.salus-aws-bridge.userPoolId.label = User Pool ID
thing-type.config.salus.salus-aws-bridge.username.label = Username/Email
thing-type.config.salus.salus-aws-bridge.username.description = The username or email associated with your Salus account. This is required for authentication with the Salus cloud.
thing-type.config.salus.salus-cloud-bridge.maxHttpRetries.label = Max HTTP Retries
thing-type.config.salus.salus-cloud-bridge.maxHttpRetries.description = How many times HTTP requests can be retried
thing-type.config.salus.salus-cloud-bridge.password.label = Password
@ -22,7 +45,7 @@ thing-type.config.salus.salus-cloud-bridge.propertiesRefreshInterval.label = Dev
thing-type.config.salus.salus-cloud-bridge.propertiesRefreshInterval.description = The period (in seconds) after which the cached device properties will be discarded and re-fetched fresh from the Salus cloud.
thing-type.config.salus.salus-cloud-bridge.refreshInterval.label = Refresh Interval
thing-type.config.salus.salus-cloud-bridge.refreshInterval.description = The interval in seconds at which the connection to the Salus cloud should be refreshed to ensure up-to-date data.
thing-type.config.salus.salus-cloud-bridge.url.label = Salus base URL
thing-type.config.salus.salus-cloud-bridge.url.label = Salus API URL
thing-type.config.salus.salus-cloud-bridge.url.description = The base URL for the Salus cloud. Typically, this should remain as the default, unless directed to change by Salus.
thing-type.config.salus.salus-cloud-bridge.username.label = Username/Email
thing-type.config.salus.salus-cloud-bridge.username.description = The username or email associated with your Salus account. This is required for authentication with the Salus cloud.
@ -33,17 +56,17 @@ thing-type.config.salus.salus-it600-device.dsn.description = Data Source Name (D
# channel types
channel-type.salus.generic-input-bool-channel.label = Generic Bool Input Channel
channel-type.salus.generic-input-bool-channel.label = Generic Bool Input
channel-type.salus.generic-input-bool-channel.description = This channel type represents a generic input. The channel is write-only and its state is represented as a boolean.
channel-type.salus.generic-input-channel.label = Generic Input Channel
channel-type.salus.generic-input-channel.label = Generic Input
channel-type.salus.generic-input-channel.description = This channel type represents a generic input. The channel is write-only and its state is represented as a string.
channel-type.salus.generic-input-number-channel.label = Generic Number Input Channel
channel-type.salus.generic-input-number-channel.label = Generic Number Input
channel-type.salus.generic-input-number-channel.description = This channel type represents a generic input. The channel is write-only and its state is represented as a numeric.
channel-type.salus.generic-output-bool-channel.label = Generic Bool Output Channel
channel-type.salus.generic-output-bool-channel.label = Generic Bool Output
channel-type.salus.generic-output-bool-channel.description = This channel type represents a generic output. The channel is read-only and its state is represented as a boolean.
channel-type.salus.generic-output-channel.label = Generic Output Channel
channel-type.salus.generic-output-channel.label = Generic Output
channel-type.salus.generic-output-channel.description = This channel type represents a generic output. The channel is read-only and its state is represented as a string.
channel-type.salus.generic-output-number-channel.label = Generic Number Output Channel
channel-type.salus.generic-output-number-channel.label = Generic Number Output
channel-type.salus.generic-output-number-channel.description = This channel type represents a generic output. The channel is read-only and its state is represented as a numeric.
channel-type.salus.it600-expected-temp-channel.label = Expected Temperature
channel-type.salus.it600-expected-temp-channel.description = Sets the desired temperature in room
@ -55,14 +78,15 @@ channel-type.salus.it600-work-type-channel.state.option.OFF = OFF
channel-type.salus.it600-work-type-channel.state.option.MANUAL = Manual
channel-type.salus.it600-work-type-channel.state.option.AUTO = Automatic
channel-type.salus.it600-work-type-channel.state.option.TEMPORARY_MANUAL = Temporary Manual
channel-type.salus.temperature-input-channel.label = Generic Input Temperature Channel
channel-type.salus.temperature-input-channel.label = Generic Input Temperature
channel-type.salus.temperature-input-channel.description = This channel type represents a generic input. The channel is write-only and its state is represented as a temperature (numeric).
channel-type.salus.temperature-output-channel.label = Generic Output Temperature Channel
channel-type.salus.temperature-output-channel.label = Generic Output Temperature
channel-type.salus.temperature-output-channel.description = This channel type represents a generic output. The channel is read-only and its state is represented as a temperature (numeric).
# code i8n
cloud-bridge-handler.initialize.username-pass-not-valid = Username or password is missing
cloud-bridge-handler.initialize.auth-exception = Cannot connect to Salus Cloud! Probably username/password mismatch! {0}
cloud-bridge-handler.initialize.cannot-connect-to-cloud = Cannot connect to Salus Cloud! Probably username/password mismatch! {0}
cloud-bridge-handler.errors.http = There was an error when sending a request to the Salus Cloud. {0}
device-handler.initialize.errors.no-bridge = There is no bridge for this thing. Remove it and add it again.

View File

@ -0,0 +1,101 @@
<?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>

View File

@ -7,6 +7,7 @@
<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>

View File

@ -8,6 +8,7 @@
<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>

View File

@ -10,8 +10,9 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.salus.internal.rest;
package org.openhab.binding.salus.internal.cloud.rest;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
@ -28,13 +29,20 @@ import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.openhab.binding.salus.internal.rest.Device;
import org.openhab.binding.salus.internal.rest.DeviceProperty;
import org.openhab.binding.salus.internal.rest.GsonMapper;
import org.openhab.binding.salus.internal.rest.RestClient;
import org.openhab.binding.salus.internal.rest.exceptions.AuthSalusApiException;
import org.openhab.binding.salus.internal.rest.exceptions.HttpSalusApiException;
import org.openhab.binding.salus.internal.rest.exceptions.SalusApiException;
/**
* @author Martin Grześlowski - Initial contribution
*/
@SuppressWarnings("DataFlowIssue")
@NonNullByDefault
public class SalusApiTest {
public class HttpSalusApiTest {
// Find devices returns sorted set of devices
@Test
@ -42,7 +50,7 @@ public class SalusApiTest {
public void testFindDevicesReturnsSortedSetOfDevices() throws Exception {
// Given
var username = "correct_username";
var password = "correct_password".toCharArray();
var password = "correct_password".getBytes(UTF_8);
var baseUrl = "https://example.com";
var restClient = mock(RestClient.class);
var mapper = mock(GsonMapper.class);
@ -55,7 +63,7 @@ public class SalusApiTest {
var devices = new ArrayList<Device>();
when(mapper.parseDevices(anyString())).thenReturn(devices);
var salusApi = new SalusApi(username, password, baseUrl, restClient, mapper, clock);
var salusApi = new HttpSalusApi(username, password, baseUrl, restClient, mapper, clock);
setAuthToken(salusApi, restClient, mapper, authToken);
// When
@ -71,7 +79,7 @@ public class SalusApiTest {
public void testFindDevicePropertiesReturnsSortedSetOfDeviceProperties() throws Exception {
// Given
var username = "correct_username";
var password = "correct_password".toCharArray();
var password = "correct_password".getBytes(UTF_8);
var baseUrl = "https://example.com";
var restClient = mock(RestClient.class);
var mapper = mock(GsonMapper.class);
@ -84,7 +92,7 @@ public class SalusApiTest {
var deviceProperties = new ArrayList<DeviceProperty<?>>();
when(mapper.parseDeviceProperties(anyString())).thenReturn(deviceProperties);
var salusApi = new SalusApi(username, password, baseUrl, restClient, mapper, clock);
var salusApi = new HttpSalusApi(username, password, baseUrl, restClient, mapper, clock);
setAuthToken(salusApi, restClient, mapper, authToken);
// When
@ -100,7 +108,7 @@ public class SalusApiTest {
public void testSetValueForPropertyReturnsOkResponseWithDatapointValue() throws Exception {
// Given
var username = "correct_username";
var password = "correct_password".toCharArray();
var password = "correct_password".getBytes(UTF_8);
var baseUrl = "https://example.com";
var restClient = mock(RestClient.class);
var mapper = mock(GsonMapper.class);
@ -113,7 +121,7 @@ public class SalusApiTest {
var datapointValue = new Object();
when(mapper.datapointValue(anyString())).thenReturn(Optional.of(datapointValue));
var salusApi = new SalusApi(username, password, baseUrl, restClient, mapper, clock);
var salusApi = new HttpSalusApi(username, password, baseUrl, restClient, mapper, clock);
setAuthToken(salusApi, restClient, mapper, authToken);
// When
@ -129,7 +137,7 @@ public class SalusApiTest {
public void testLoginWithIncorrectCredentialsThrowsHttpUnauthorizedException() throws Exception {
// Given
var username = "incorrect_username";
var password = "incorrect_password".toCharArray();
var password = "incorrect_password".getBytes(UTF_8);
var baseUrl = "https://example.com";
var restClient = mock(RestClient.class);
var mapper = mock(GsonMapper.class);
@ -138,14 +146,14 @@ public class SalusApiTest {
when(restClient.post(anyString(), any(), any()))
.thenThrow(new HttpSalusApiException(401, "unauthorized_error_json"));
var salusApi = new SalusApi(username, password, baseUrl, restClient, mapper, clock);
var salusApi = new HttpSalusApi(username, password, baseUrl, restClient, mapper, clock);
// When
ThrowingCallable findDevicesResponse = salusApi::findDevices;
// Then
assertThatThrownBy(findDevicesResponse).isInstanceOf(HttpSalusApiException.class)
.hasMessage("HTTP Error 401: unauthorized_error_json");
assertThatThrownBy(findDevicesResponse).isInstanceOf(AuthSalusApiException.class)
.hasMessage("Could not log in, for user incorrect_username");
}
// Find devices with invalid auth token throws HttpUnauthorizedException
@ -154,7 +162,7 @@ public class SalusApiTest {
public void testFindDevicesWithInvalidAuthTokenThrowsHttpUnauthorizedException() throws Exception {
// Given
var username = "correct_username";
var password = "correct_password".toCharArray();
var password = "correct_password".getBytes(UTF_8);
var baseUrl = "https://example.com";
var restClient = mock(RestClient.class);
var mapper = mock(GsonMapper.class);
@ -163,7 +171,7 @@ public class SalusApiTest {
var authToken = new AuthToken("access_token", "refresh_token", 3600L, "role");
when(restClient.get(anyString(), any())).thenThrow(new HttpSalusApiException(401, "unauthorized_error_json"));
var salusApi = new SalusApi(username, password, baseUrl, restClient, mapper, clock);
var salusApi = new HttpSalusApi(username, password, baseUrl, restClient, mapper, clock);
setAuthToken(salusApi, restClient, mapper, authToken);
// When
@ -180,7 +188,7 @@ public class SalusApiTest {
public void testFindDevicePropertiesWithInvalidAuthTokenThrowsHttpUnauthorizedException() throws Exception {
// Given
var username = "correct_username";
var password = "correct_password".toCharArray();
var password = "correct_password".getBytes(UTF_8);
var baseUrl = "https://example.com";
var restClient = mock(RestClient.class);
var mapper = mock(GsonMapper.class);
@ -189,7 +197,7 @@ public class SalusApiTest {
var authToken = new AuthToken("access_token", "refresh_token", 3600L, "role");
when(restClient.get(anyString(), any())).thenThrow(new HttpSalusApiException(401, "unauthorized_error_json"));
var salusApi = new SalusApi(username, password, baseUrl, restClient, mapper, clock);
var salusApi = new HttpSalusApi(username, password, baseUrl, restClient, mapper, clock);
setAuthToken(salusApi, restClient, mapper, authToken);
// When
@ -206,25 +214,21 @@ public class SalusApiTest {
public void testSetValueForPropertyWithInvalidAuthTokenThrowsHttpUnauthorizedException() throws Exception {
// Given
var username = "correct_username";
var password = "correct_password".toCharArray();
var password = "correct_password".getBytes(UTF_8);
var baseUrl = "https://example.com";
var restClient = mock(RestClient.class);
var mapper = mock(GsonMapper.class);
var clock = Clock.systemDefaultZone();
var authToken = new AuthToken("access_token", "refresh_token", 3600L, "role");
when(restClient.post(anyString(), any(), any()))
.thenThrow(new HttpSalusApiException(401, "unauthorized_error_json"));
var salusApi = new SalusApi(username, password, baseUrl, restClient, mapper, clock);
var salusApi = new HttpSalusApi(username, password, baseUrl, restClient, mapper, clock);
// When
ThrowingCallable objectApiResponse = () -> salusApi.setValueForProperty("dsn", "property_name", "value");
// given
assertThatThrownBy(objectApiResponse).isInstanceOf(HttpSalusApiException.class)
.hasMessage("HTTP Error 401: unauthorized_error_json");
assertThatThrownBy(objectApiResponse).isInstanceOf(AuthSalusApiException.class)
.hasMessage("Could not log in, for user correct_username");
}
// Find device properties with invalid DSN returns ApiResponse with error
@ -233,7 +237,7 @@ public class SalusApiTest {
public void testFindDevicePropertiesWithInvalidDsnReturnsApiResponseWithError() throws Exception {
// Given
var username = "correct_username";
var password = "correct_password".toCharArray();
var password = "correct_password".getBytes(UTF_8);
var baseUrl = "https://example.com";
var restClient = mock(RestClient.class);
var mapper = mock(GsonMapper.class);
@ -242,7 +246,7 @@ public class SalusApiTest {
var authToken = new AuthToken("access_token", "refresh_token", 3600L, "role");
when(restClient.get(anyString(), any())).thenThrow(new HttpSalusApiException(404, "not found"));
var salusApi = new SalusApi(username, password, baseUrl, restClient, mapper, clock);
var salusApi = new HttpSalusApi(username, password, baseUrl, restClient, mapper, clock);
setAuthToken(salusApi, restClient, mapper, authToken);
// When
@ -258,7 +262,7 @@ public class SalusApiTest {
public void testLoginWithIncorrectCredentials3TimesThrowsHttpForbiddenException() throws Exception {
// Given
var username = "incorrect_username";
var password = "incorrect_password".toCharArray();
var password = "incorrect_password".getBytes(UTF_8);
var baseUrl = "https://example.com";
var restClient = mock(RestClient.class);
var mapper = mock(GsonMapper.class);
@ -267,20 +271,20 @@ public class SalusApiTest {
when(restClient.post(anyString(), any(), any()))
.thenThrow(new HttpSalusApiException(403, "forbidden_error_json"));
var salusApi = new SalusApi(username, password, baseUrl, restClient, mapper, clock);
var salusApi = new HttpSalusApi(username, password, baseUrl, restClient, mapper, clock);
// When
ThrowingCallable findDevicesResponse = salusApi::findDevices;
// Then
assertThatThrownBy(findDevicesResponse).isInstanceOf(HttpSalusApiException.class)
.hasMessage("HTTP Error 403: forbidden_error_json");
assertThatThrownBy(findDevicesResponse).isInstanceOf(AuthSalusApiException.class)
.hasMessage("Could not log in, for user incorrect_username");
}
private void setAuthToken(SalusApi salusApi, RestClient restClient, GsonMapper mapper, AuthToken authToken)
private void setAuthToken(HttpSalusApi salusApi, RestClient restClient, GsonMapper mapper, AuthToken authToken)
throws SalusApiException {
var username = "correct_username";
var password = "correct_password".toCharArray();
var password = "correct_password".getBytes(UTF_8);
var inputBody = "login_param_json";
when(mapper.loginParam(username, password)).thenReturn(inputBody);
var authTokenJson = "auth_token";

View File

@ -24,28 +24,31 @@ import java.util.List;
import java.util.Random;
import java.util.TreeSet;
import javax.validation.constraints.NotNull;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.openhab.binding.salus.internal.handler.CloudApi;
import org.openhab.binding.salus.internal.handler.CloudBridgeHandler;
import org.openhab.binding.salus.internal.rest.Device;
import org.openhab.binding.salus.internal.rest.SalusApiException;
import org.openhab.binding.salus.internal.rest.exceptions.SalusApiException;
import org.openhab.core.config.discovery.DiscoveryListener;
import org.openhab.core.thing.ThingUID;
/**
* @author Martin Grześlowski - Initial contribution
*/
public class CloudDiscoveryTest {
@NonNullByDefault
public class SalusDiscoveryTest {
@Test
@DisplayName("Method filters out disconnected devices and adds connected devices as things using addThing method")
void testFiltersOutDisconnectedDevicesAndAddsConnectedDevicesAsThings() throws SalusApiException {
void testFiltersOutDisconnectedDevicesAndAddsConnectedDevicesAsThings() throws Exception {
// Given
var cloudApi = mock(CloudApi.class);
var bridgeHandler = mock(CloudBridgeHandler.class);
var bridgeUid = new ThingUID("salus", "salus-device", "boo");
var discoveryService = new CloudDiscovery(bridgeHandler, cloudApi, bridgeUid);
var discoveryService = new SalusDiscovery(cloudApi, bridgeUid);
var discoveryListener = mock(DiscoveryListener.class);
discoveryService.addDiscoveryListener(discoveryListener);
var device1 = randomDevice(true);
@ -73,12 +76,11 @@ public class CloudDiscoveryTest {
@Test
@DisplayName("Cloud API throws an exception during device retrieval, method logs the error")
void testLogsErrorWhenCloudApiThrowsException() throws SalusApiException {
void testLogsErrorWhenCloudApiThrowsException() throws Exception {
// Given
var cloudApi = mock(CloudApi.class);
var bridgeHandler = mock(CloudBridgeHandler.class);
var bridgeUid = mock(ThingUID.class);
var discoveryService = new CloudDiscovery(bridgeHandler, cloudApi, bridgeUid);
var discoveryService = new SalusDiscovery(cloudApi, bridgeUid);
given(cloudApi.findDevices()).willThrow(new SalusApiException("API error"));
@ -91,10 +93,10 @@ public class CloudDiscoveryTest {
private Device randomDevice(boolean connected) {
var random = new Random();
var map = new HashMap<String, Object>();
var map = new HashMap<@NotNull String, @Nullable Object>();
if (connected) {
map.put("connection_status", "online");
}
return new Device("dsn-" + random.nextInt(), "name-" + random.nextInt(), map);
return new Device("dsn-" + random.nextInt(), "name-" + random.nextInt(), connected, map);
}
}

View File

@ -36,10 +36,10 @@ class DeviceTest {
// 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();
@ -52,10 +52,10 @@ class DeviceTest {
// 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();
@ -67,10 +67,10 @@ class DeviceTest {
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();
@ -82,10 +82,10 @@ class DeviceTest {
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();
@ -98,10 +98,10 @@ class DeviceTest {
// 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();
@ -114,10 +114,10 @@ class DeviceTest {
// 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();
@ -133,7 +133,7 @@ class DeviceTest {
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();
@ -152,8 +152,8 @@ class DeviceTest {
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);
@ -172,8 +172,8 @@ class DeviceTest {
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);
@ -186,26 +186,26 @@ class DeviceTest {
assertThat(result3).isZero();
}
// The isConnected method should return true if the connection_status property is "online".
// The connected method should return true if the connection_status property is "online".
@Test
@DisplayName("The isConnected method should return true if the connection_status property is \"online\"")
public void testIsConnectedMethodShouldReturnTrueIfConnectionStatusIsOnline() {
@DisplayName("The connected method should return true if the connection_status property is \"online\"")
public void testconnectedMethodShouldReturnTrueIfConnectionStatusIsOnline() {
// Given
String dsn = "123456";
String name = "Device";
Map<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.
@ -217,7 +217,7 @@ class DeviceTest {
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();

View File

@ -12,6 +12,7 @@
*/
package org.openhab.binding.salus.internal.rest;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.Collections;
@ -20,6 +21,7 @@ import java.util.Optional;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
import org.openhab.binding.salus.internal.cloud.rest.AuthToken;
/**
* @author Martin Grześlowski - Initial contribution
@ -33,7 +35,7 @@ public class GsonMapperTest {
// Given
GsonMapper gsonMapper = GsonMapper.INSTANCE;
String username = "test@example.com";
char[] password = "password".toCharArray();
byte[] password = "password".getBytes(UTF_8);
String expectedJson1 = "{\"user\":{\"email\":\"test@example.com\",\"password\":\"password\"}}";
String expectedJson2 = "{\"user\":{\"password\":\"password\",\"email\":\"test@example.com\"}}";
@ -65,8 +67,8 @@ public class GsonMapperTest {
// Given
GsonMapper gsonMapper = GsonMapper.INSTANCE;
String json = "[{\"device\":{\"dsn\":\"123\",\"product_name\":\"Product 1\"}},{\"device\":{\"dsn\":\"456\",\"product_name\":\"Product 2\"}}]";
List<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);

View File

@ -29,6 +29,8 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.openhab.binding.salus.internal.rest.RestClient.Content;
import org.openhab.binding.salus.internal.rest.RestClient.Header;
import org.openhab.binding.salus.internal.rest.exceptions.HttpSalusApiException;
import org.openhab.binding.salus.internal.rest.exceptions.SalusApiException;
/**
* @author Martin Grześlowski - Initial contribution