]> git.basschouten.com Git - openhab-addons.git/blob
d3fd2d0e36f0a321e454a5be1edfe5b9884a5e4a
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.salus.internal.rest;
14
15 import static java.util.Objects.requireNonNull;
16
17 import java.time.Clock;
18 import java.time.LocalDateTime;
19 import java.util.SortedSet;
20 import java.util.TreeSet;
21
22 import org.eclipse.jdt.annotation.NonNullByDefault;
23 import org.eclipse.jdt.annotation.Nullable;
24 import org.slf4j.Logger;
25 import org.slf4j.LoggerFactory;
26
27 /**
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.
31  *
32  * @author Martin GrzeĊ›lowski - Initial contribution
33  */
34 @NonNullByDefault
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;
44     @Nullable
45     private AuthToken authToken;
46     @Nullable
47     private LocalDateTime authTokenExpireTime;
48     private final Clock clock;
49
50     public SalusApi(String username, char[] password, String baseUrl, RestClient restClient, GsonMapper mapper,
51             Clock clock) {
52         this.username = username;
53         this.password = password;
54         this.baseUrl = removeTrailingSlash(baseUrl);
55         this.restClient = restClient;
56         this.mapper = mapper;
57         this.clock = clock;
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(".", "_") + "]");
61     }
62
63     public SalusApi(String username, char[] password, String baseUrl, RestClient restClient, GsonMapper mapper) {
64         this(username, password, baseUrl, restClient, mapper, Clock.systemDefaultZone());
65     }
66
67     private @Nullable String get(String url, RestClient.Header header, int retryAttempt) throws SalusApiException {
68         refreshAccessToken();
69         try {
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);
76                 }
77                 logger.debug("Could not refresh access token after {} retries", MAX_RETRIES);
78             }
79             throw ex;
80         }
81     }
82
83     private @Nullable String post(String url, RestClient.Content content, RestClient.Header header, int retryAttempt)
84             throws SalusApiException {
85         refreshAccessToken();
86         try {
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);
93                 }
94                 logger.debug("Could not refresh access token after {} retries", MAX_RETRIES);
95             }
96             throw ex;
97         }
98     }
99
100     private static String removeTrailingSlash(String str) {
101         if (str.endsWith("/")) {
102             return str.substring(0, str.length() - 1);
103         }
104         return str;
105     }
106
107     private void login(String username, char[] password) throws SalusApiException {
108         login(username, password, 1);
109     }
110
111     private void login(String username, char[] password, int retryAttempt) throws SalusApiException {
112         logger.debug("Login with username '{}', retryAttempt={}", username, retryAttempt);
113         authToken = null;
114         authTokenExpireTime = null;
115         var finalUrl = url("/users/sign_in.json");
116         var inputBody = mapper.loginParam(username, password);
117         try {
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");
122             }
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);
134                 }
135                 throw ex;
136             }
137             throw ex;
138         }
139     }
140
141     private void forceRefreshAccessToken() throws SalusApiException {
142         logger.debug("Force refresh access token");
143         authToken = null;
144         authTokenExpireTime = null;
145         refreshAccessToken();
146     }
147
148     private void refreshAccessToken() throws SalusApiException {
149         if (this.authToken == null || isExpiredToken()) {
150             try {
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;
156                 throw ex;
157             }
158         }
159     }
160
161     private boolean isExpiredToken() {
162         var expireTime = authTokenExpireTime;
163         return expireTime == null || LocalDateTime.now(clock).isAfter(expireTime);
164     }
165
166     private String url(String url) {
167         return baseUrl + url;
168     }
169
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)));
174     }
175
176     private RestClient.Header authHeader() {
177         return new RestClient.Header("Authorization", "auth_token " + requireNonNull(authToken).accessToken());
178     }
179
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));
185         }
186         return new TreeSet<>(mapper.parseDeviceProperties(response));
187     }
188
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"));
196     }
197 }