2 * Copyright (c) 2010-2024 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.salus.internal.rest;
15 import static java.util.Objects.requireNonNull;
17 import java.time.Clock;
18 import java.time.LocalDateTime;
19 import java.util.SortedSet;
20 import java.util.TreeSet;
22 import org.eclipse.jdt.annotation.NonNullByDefault;
23 import org.eclipse.jdt.annotation.Nullable;
24 import org.slf4j.Logger;
25 import org.slf4j.LoggerFactory;
28 * The SalusApi class is responsible for interacting with a REST API to perform various operations related to the Salus
29 * system. It handles authentication, token management, and provides methods to retrieve and manipulate device
30 * information and properties.
32 * @author Martin GrzeĊlowski - Initial contribution
35 public class SalusApi {
36 private static final int MAX_RETRIES = 3;
37 private static final long TOKEN_EXPIRE_TIME_ADJUSTMENT_SECONDS = 3;
38 private final Logger logger;
39 private final String username;
40 private final char[] password;
41 private final String baseUrl;
42 private final RestClient restClient;
43 private final GsonMapper mapper;
45 private AuthToken authToken;
47 private LocalDateTime authTokenExpireTime;
48 private final Clock clock;
50 public SalusApi(String username, char[] password, String baseUrl, RestClient restClient, GsonMapper mapper,
52 this.username = username;
53 this.password = password;
54 this.baseUrl = removeTrailingSlash(baseUrl);
55 this.restClient = restClient;
58 // thanks to this, logger will always inform for which rest client it's doing the job
59 // it's helpful when more than one SalusApi exists
60 logger = LoggerFactory.getLogger(SalusApi.class.getName() + "[" + username.replace(".", "_") + "]");
63 public SalusApi(String username, char[] password, String baseUrl, RestClient restClient, GsonMapper mapper) {
64 this(username, password, baseUrl, restClient, mapper, Clock.systemDefaultZone());
67 private @Nullable String get(String url, RestClient.Header header, int retryAttempt) throws SalusApiException {
70 return restClient.get(url, authHeader());
71 } catch (HttpSalusApiException ex) {
72 if (ex.getCode() == 401) {
73 if (retryAttempt <= MAX_RETRIES) {
74 forceRefreshAccessToken();
75 return get(url, header, retryAttempt + 1);
77 logger.debug("Could not refresh access token after {} retries", MAX_RETRIES);
83 private @Nullable String post(String url, RestClient.Content content, RestClient.Header header, int retryAttempt)
84 throws SalusApiException {
87 return restClient.post(url, content, header);
88 } catch (HttpSalusApiException ex) {
89 if (ex.getCode() == 401) {
90 if (retryAttempt <= MAX_RETRIES) {
91 forceRefreshAccessToken();
92 return post(url, content, header, retryAttempt + 1);
94 logger.debug("Could not refresh access token after {} retries", MAX_RETRIES);
100 private static String removeTrailingSlash(String str) {
101 if (str.endsWith("/")) {
102 return str.substring(0, str.length() - 1);
107 private void login(String username, char[] password) throws SalusApiException {
108 login(username, password, 1);
111 private void login(String username, char[] password, int retryAttempt) throws SalusApiException {
112 logger.debug("Login with username '{}', retryAttempt={}", username, retryAttempt);
114 authTokenExpireTime = null;
115 var finalUrl = url("/users/sign_in.json");
116 var inputBody = mapper.loginParam(username, password);
118 var response = restClient.post(finalUrl, new RestClient.Content(inputBody, "application/json"),
119 new RestClient.Header("Accept", "application/json"));
120 if (response == null) {
121 throw new HttpSalusApiException(401, "No response token from server");
123 var token = authToken = mapper.authToken(response);
124 authTokenExpireTime = LocalDateTime.now(clock).plusSeconds(token.expiresIn())
125 // this is to account that there is a delay between server setting `expires_in`
126 // and client (OpenHAB) receiving it
127 .minusSeconds(TOKEN_EXPIRE_TIME_ADJUSTMENT_SECONDS);
128 logger.debug("Correctly logged in for user {}, role={}, expires at {} ({} secs)", username, token.role(),
129 authTokenExpireTime, token.expiresIn());
130 } catch (HttpSalusApiException ex) {
131 if (ex.getCode() == 401 || ex.getCode() == 403) {
132 if (retryAttempt < MAX_RETRIES) {
133 login(username, password, retryAttempt + 1);
141 private void forceRefreshAccessToken() throws SalusApiException {
142 logger.debug("Force refresh access token");
144 authTokenExpireTime = null;
145 refreshAccessToken();
148 private void refreshAccessToken() throws SalusApiException {
149 if (this.authToken == null || isExpiredToken()) {
151 login(username, password);
152 } catch (SalusApiException ex) {
153 logger.warn("Accesstoken could not be acquired, for user '{}', response={}", username, ex.getMessage());
154 this.authToken = null;
155 this.authTokenExpireTime = null;
161 private boolean isExpiredToken() {
162 var expireTime = authTokenExpireTime;
163 return expireTime == null || LocalDateTime.now(clock).isAfter(expireTime);
166 private String url(String url) {
167 return baseUrl + url;
170 public SortedSet<Device> findDevices() throws SalusApiException {
171 refreshAccessToken();
172 var response = get(url("/apiv1/devices.json"), authHeader(), 1);
173 return new TreeSet<>(mapper.parseDevices(requireNonNull(response)));
176 private RestClient.Header authHeader() {
177 return new RestClient.Header("Authorization", "auth_token " + requireNonNull(authToken).accessToken());
180 public SortedSet<DeviceProperty<?>> findDeviceProperties(String dsn) throws SalusApiException {
181 refreshAccessToken();
182 var response = get(url("/apiv1/dsns/" + dsn + "/properties.json"), authHeader(), 1);
183 if (response == null) {
184 throw new SalusApiException("No device properties for device %s".formatted(dsn));
186 return new TreeSet<>(mapper.parseDeviceProperties(response));
189 public Object setValueForProperty(String dsn, String propertyName, Object value) throws SalusApiException {
190 refreshAccessToken();
191 var finalUrl = url("/apiv1/dsns/" + dsn + "/properties/" + propertyName + "/datapoints.json");
192 var json = mapper.datapointParam(value);
193 var response = post(finalUrl, new RestClient.Content(json), authHeader(), 1);
194 var datapointValue = mapper.datapointValue(response);
195 return datapointValue.orElseThrow(() -> new HttpSalusApiException(404, "No datapoint in return"));