2 * Copyright (c) 2010-2023 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.bticinosmarther.internal.api;
15 import static org.eclipse.jetty.http.HttpMethod.*;
16 import static org.openhab.binding.bticinosmarther.internal.SmartherBindingConstants.*;
18 import java.io.IOException;
19 import java.util.ArrayList;
20 import java.util.Collections;
21 import java.util.IdentityHashMap;
22 import java.util.List;
24 import java.util.Objects;
25 import java.util.concurrent.ScheduledExecutorService;
26 import java.util.function.Function;
28 import javax.measure.quantity.Temperature;
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.eclipse.jetty.client.HttpClient;
33 import org.eclipse.jetty.client.api.ContentResponse;
34 import org.eclipse.jetty.client.api.Request;
35 import org.eclipse.jetty.client.util.StringContentProvider;
36 import org.eclipse.jetty.http.HttpMethod;
37 import org.eclipse.jetty.http.HttpStatus;
38 import org.openhab.binding.bticinosmarther.internal.api.dto.Chronothermostat;
39 import org.openhab.binding.bticinosmarther.internal.api.dto.Enums.MeasureUnit;
40 import org.openhab.binding.bticinosmarther.internal.api.dto.Module;
41 import org.openhab.binding.bticinosmarther.internal.api.dto.ModuleStatus;
42 import org.openhab.binding.bticinosmarther.internal.api.dto.Plant;
43 import org.openhab.binding.bticinosmarther.internal.api.dto.Plants;
44 import org.openhab.binding.bticinosmarther.internal.api.dto.Program;
45 import org.openhab.binding.bticinosmarther.internal.api.dto.Subscription;
46 import org.openhab.binding.bticinosmarther.internal.api.dto.Topology;
47 import org.openhab.binding.bticinosmarther.internal.api.exception.SmartherAuthorizationException;
48 import org.openhab.binding.bticinosmarther.internal.api.exception.SmartherGatewayException;
49 import org.openhab.binding.bticinosmarther.internal.api.exception.SmartherTokenExpiredException;
50 import org.openhab.binding.bticinosmarther.internal.model.ModuleSettings;
51 import org.openhab.binding.bticinosmarther.internal.util.ModelUtil;
52 import org.openhab.binding.bticinosmarther.internal.util.StringUtil;
53 import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
54 import org.openhab.core.auth.client.oauth2.OAuthClientService;
55 import org.openhab.core.auth.client.oauth2.OAuthException;
56 import org.openhab.core.auth.client.oauth2.OAuthResponseException;
57 import org.openhab.core.library.types.QuantityType;
58 import org.openhab.core.library.unit.SIUnits;
59 import org.slf4j.Logger;
60 import org.slf4j.LoggerFactory;
62 import com.google.gson.JsonSyntaxException;
63 import com.google.gson.reflect.TypeToken;
66 * The {@code SmartherApi} class is used to communicate with the BTicino/Legrand API gateway.
68 * @author Fabio Possieri - Initial contribution
71 public class SmartherApi {
73 private static final String CONTENT_TYPE = "application/json";
74 private static final String BEARER = "Bearer ";
76 // API gateway request headers
77 private static final String HEADER_ACCEPT = "Accept";
78 // API gateway request attributes
79 private static final String ATTR_FUNCTION = "function";
80 private static final String ATTR_MODE = "mode";
81 private static final String ATTR_PROGRAMS = "programs";
82 private static final String ATTR_NUMBER = "number";
83 private static final String ATTR_SETPOINT = "setPoint";
84 private static final String ATTR_VALUE = "value";
85 private static final String ATTR_UNIT = "unit";
86 private static final String ATTR_ACTIVATION_TIME = "activationTime";
87 private static final String ATTR_ENDPOINT_URL = "EndPointUrl";
88 // API gateway operation paths
89 private static final String PATH_PLANTS = "/plants";
90 private static final String PATH_TOPOLOGY = PATH_PLANTS + "/%s/topology";
91 private static final String PATH_MODULE = "/chronothermostat/thermoregulation/addressLocation/plants/%s/modules/parameter/id/value/%s";
92 private static final String PATH_PROGRAMS = "/programlist";
93 private static final String PATH_SUBSCRIPTIONS = "/subscription";
94 private static final String PATH_SUBSCRIBE = PATH_PLANTS + "/%s/subscription";
95 private static final String PATH_UNSUBSCRIBE = PATH_SUBSCRIBE + "/%s";
97 private final Logger logger = LoggerFactory.getLogger(SmartherApi.class);
99 private final OAuthClientService oAuthClientService;
100 private final String oAuthSubscriptionKey;
101 private final SmartherApiConnector connector;
104 * Constructs a {@code SmartherApi} to the API gateway with the specified OAuth2 attributes (subscription key and
105 * client service), scheduler service and http client.
107 * @param clientService
108 * the OAuth2 authorization client service to be used
109 * @param subscriptionKey
110 * the OAuth2 subscription key to be used with the given client service
112 * the scheduler to be used to reschedule calls when rate limit exceeded or call not succeeded
114 * the http client to be used to make http calls to the API gateway
116 public SmartherApi(final OAuthClientService clientService, final String subscriptionKey,
117 final ScheduledExecutorService scheduler, final HttpClient httpClient) {
118 this.oAuthClientService = clientService;
119 this.oAuthSubscriptionKey = subscriptionKey;
120 this.connector = new SmartherApiConnector(scheduler, httpClient);
124 * Returns the plants registered under the Smarther account the bridge has been configured with.
126 * @return the list of registered plants, or an empty {@link List} in case of no plants found
128 * @throws {@link SmartherGatewayException}
129 * in case of communication issues with the API gateway
131 public List<Plant> getPlants() throws SmartherGatewayException {
133 final ContentResponse response = requestBasic(GET, PATH_PLANTS);
134 if (response.getStatus() == HttpStatus.NO_CONTENT_204) {
135 return new ArrayList<>();
137 return ModelUtil.gsonInstance().fromJson(response.getContentAsString(), Plants.class).getPlants();
139 } catch (JsonSyntaxException e) {
140 throw new SmartherGatewayException(e.getMessage());
145 * Returns the chronothermostat modules registered in the given plant.
148 * the identifier of the plant
150 * @return the list of registered modules, or an empty {@link List} in case the plant contains no module
152 * @throws {@link SmartherGatewayException}
153 * in case of communication issues with the API gateway
155 public List<Module> getPlantModules(String plantId) throws SmartherGatewayException {
157 final ContentResponse response = requestBasic(GET, String.format(PATH_TOPOLOGY, plantId));
158 final Topology topology = ModelUtil.gsonInstance().fromJson(response.getContentAsString(), Topology.class);
159 return topology.getModules();
160 } catch (JsonSyntaxException e) {
161 throw new SmartherGatewayException(e.getMessage());
166 * Returns the current status of a given chronothermostat module.
169 * the identifier of the plant
171 * the identifier of the chronothermostat module inside the plant
173 * @return the current status of the chronothermostat module
175 * @throws {@link SmartherGatewayException}
176 * in case of communication issues with the API gateway
178 public ModuleStatus getModuleStatus(String plantId, String moduleId) throws SmartherGatewayException {
180 final ContentResponse response = requestModule(GET, plantId, moduleId, null);
181 ModuleStatus moduleStatus = ModelUtil.gsonInstance().fromJson(response.getContentAsString(),
183 return Objects.requireNonNull(moduleStatus);
184 } catch (JsonSyntaxException e) {
185 throw new SmartherGatewayException(e.getMessage());
190 * Sends new settings to be applied to a given chronothermostat module.
193 * the module settings to be applied
195 * @return {@code true} if the settings have been successfully applied, {@code false} otherwise
197 * @throws {@link SmartherGatewayException}
198 * in case of communication issues with the API gateway
200 public boolean setModuleStatus(ModuleSettings settings) throws SmartherGatewayException {
201 // Prepare request payload
202 Map<String, Object> rootMap = new IdentityHashMap<>();
203 rootMap.put(ATTR_FUNCTION, settings.getFunction().getValue());
204 rootMap.put(ATTR_MODE, settings.getMode().getValue());
205 switch (settings.getMode()) {
207 // {"function":"heating","mode":"automatic","programs":[{"number":0}]}
208 Map<String, Integer> programMap = new IdentityHashMap<String, Integer>();
209 programMap.put(ATTR_NUMBER, Integer.valueOf(settings.getProgram()));
210 List<Map<String, Integer>> programsList = new ArrayList<>();
211 programsList.add(programMap);
212 rootMap.put(ATTR_PROGRAMS, programsList);
215 // {"function":"heating","mode":"manual","setPoint":{"value":0.0,"unit":"C"},"activationTime":"X"}
216 QuantityType<Temperature> newTemperature = settings.getSetPointTemperature(SIUnits.CELSIUS);
217 if (newTemperature == null) {
218 throw new SmartherGatewayException("Invalid temperature unit transformation");
220 Map<String, Object> setPointMap = new IdentityHashMap<String, Object>();
221 setPointMap.put(ATTR_VALUE, newTemperature.doubleValue());
222 setPointMap.put(ATTR_UNIT, MeasureUnit.CELSIUS.getValue());
223 rootMap.put(ATTR_SETPOINT, setPointMap);
224 rootMap.put(ATTR_ACTIVATION_TIME, settings.getActivationTime());
227 // {"function":"heating","mode":"boost","activationTime":"X"}
228 rootMap.put(ATTR_ACTIVATION_TIME, settings.getActivationTime());
231 // {"function":"heating","mode":"off"}
234 // {"function":"heating","mode":"protection"}
237 final String jsonPayload = ModelUtil.gsonInstance().toJson(rootMap);
239 // Send request to server
240 final ContentResponse response = requestModule(POST, settings.getPlantId(), settings.getModuleId(),
242 return (response.getStatus() == HttpStatus.OK_200);
246 * Returns the automatic mode programs registered for the given chronothermostat module.
249 * the identifier of the plant
251 * the identifier of the chronothermostat module inside the plant
253 * @return the list of registered programs, or an empty {@link List} in case of no programs found
255 * @throws {@link SmartherGatewayException}
256 * in case of communication issues with the API gateway
258 public List<Program> getModulePrograms(String plantId, String moduleId) throws SmartherGatewayException {
260 final ContentResponse response = requestModule(GET, plantId, moduleId, PATH_PROGRAMS, null);
261 final ModuleStatus moduleStatus = ModelUtil.gsonInstance().fromJson(response.getContentAsString(),
264 final Chronothermostat chronothermostat = moduleStatus.toChronothermostat();
265 return (chronothermostat != null) ? chronothermostat.getPrograms() : Collections.emptyList();
266 } catch (JsonSyntaxException e) {
267 throw new SmartherGatewayException(e.getMessage());
272 * Returns the subscriptions registered to the C2C Webhook, where modules status notifications are currently sent
273 * for all the plants.
275 * @return the list of registered subscriptions, or an empty {@link List} in case of no subscriptions found
277 * @throws {@link SmartherGatewayException}
278 * in case of communication issues with the API gateway
280 public List<Subscription> getSubscriptions() throws SmartherGatewayException {
282 final ContentResponse response = requestBasic(GET, PATH_SUBSCRIPTIONS);
283 if (response.getStatus() == HttpStatus.NO_CONTENT_204) {
284 return new ArrayList<>();
286 List<Subscription> subscriptions = ModelUtil.gsonInstance().fromJson(response.getContentAsString(),
287 new TypeToken<List<Subscription>>() {
289 if (subscriptions == null) {
290 throw new SmartherGatewayException("fromJson returned null");
292 return subscriptions;
294 } catch (JsonSyntaxException e) {
295 throw new SmartherGatewayException(e.getMessage());
300 * Subscribes a plant to the C2C Webhook to start receiving modules status notifications.
303 * the identifier of the plant to be subscribed
304 * @param notificationUrl
305 * the url notifications will have to be sent to for the given plant
307 * @return the identifier this subscription has been registered under
309 * @throws {@link SmartherGatewayException}
310 * in case of communication issues with the API gateway
312 public String subscribePlant(String plantId, String notificationUrl) throws SmartherGatewayException {
314 // Prepare request payload
315 Map<String, Object> rootMap = new IdentityHashMap<String, Object>();
316 rootMap.put(ATTR_ENDPOINT_URL, notificationUrl);
317 final String jsonPayload = ModelUtil.gsonInstance().toJson(rootMap);
318 // Send request to server
319 final ContentResponse response = requestBasic(POST, String.format(PATH_SUBSCRIBE, plantId), jsonPayload);
320 // Handle response payload
321 final Subscription subscription = ModelUtil.gsonInstance().fromJson(response.getContentAsString(),
323 return subscription.getSubscriptionId();
324 } catch (JsonSyntaxException e) {
325 throw new SmartherGatewayException(e.getMessage());
330 * Unsubscribes a plant from the C2C Webhook to stop receiving modules status notifications.
333 * the identifier of the plant to be unsubscribed
334 * @param subscriptionId
335 * the identifier of the subscription to be removed for the given plant
337 * @return {@code true} if the plant is successfully unsubscribed, {@code false} otherwise
339 * @throws {@link SmartherGatewayException}
340 * in case of communication issues with the API gateway
342 public boolean unsubscribePlant(String plantId, String subscriptionId) throws SmartherGatewayException {
343 final ContentResponse response = requestBasic(DELETE, String.format(PATH_UNSUBSCRIBE, plantId, subscriptionId));
344 return (response.getStatus() == HttpStatus.OK_200);
347 // ===========================================================================
349 // Internal API call handling methods
351 // ===========================================================================
354 * Calls the API gateway with the given http method, request url and actual data.
357 * the http method to make the call with
359 * the API operation url to call
361 * the actual data to send in the request body, may be {@code null}
363 * @return the response received from the API gateway
365 * @throws {@link SmartherGatewayException}
366 * in case of communication issues with the API gateway
368 private ContentResponse requestBasic(HttpMethod method, String url, @Nullable String requestData)
369 throws SmartherGatewayException {
370 return request(method, SMARTHER_API_URL + url, requestData);
374 * Calls the API gateway with the given http method and request url.
377 * the http method to make the call with
379 * the API operation url to call
381 * @return the response received from the API gateway
383 * @throws {@link SmartherGatewayException}
384 * in case of communication issues with the API gateway
386 private ContentResponse requestBasic(HttpMethod method, String url) throws SmartherGatewayException {
387 return requestBasic(method, url, null);
391 * Calls the API gateway with the given http method, plant id, module id, request path and actual data.
394 * the http method to make the call with
396 * the identifier of the plant to use
398 * the identifier of the module to use
400 * the API operation relative path to call, may be {@code null}
402 * the actual data to send in the request body, may be {@code null}
404 * @return the response received from the API gateway
406 * @throws {@link SmartherGatewayException}
407 * in case of communication issues with the API gateway
409 private ContentResponse requestModule(HttpMethod method, String plantId, String moduleId, @Nullable String path,
410 @Nullable String requestData) throws SmartherGatewayException {
411 final String url = String.format(PATH_MODULE, plantId, moduleId) + StringUtil.defaultString(path);
412 return requestBasic(method, url, requestData);
416 * Calls the API gateway with the given http method, plant id, module id and actual data.
419 * the http method to make the call with
421 * the identifier of the plant to use
423 * the identifier of the module to use
425 * the actual data to send in the request body, may be {@code null}
427 * @return the response received from the API gateway
429 * @throws {@link SmartherGatewayException}
430 * in case of communication issues with the API gateway
432 private ContentResponse requestModule(HttpMethod method, String plantId, String moduleId,
433 @Nullable String requestData) throws SmartherGatewayException {
434 return requestModule(method, plantId, moduleId, null, requestData);
438 * Calls the API gateway with the given http method, request url and actual data.
441 * the http method to make the call with
443 * the API operation url to call
445 * the actual data to send in the request body, may be {@code null}
447 * @return the response received from the API gateway
449 * @throws {@link SmartherGatewayException}
450 * in case of communication issues with the API gateway
452 private ContentResponse request(HttpMethod method, String url, @Nullable String requestData)
453 throws SmartherGatewayException {
454 logger.debug("Request: ({}) {} - {}", method, url, StringUtil.defaultString(requestData));
455 Function<HttpClient, Request> call = httpClient -> httpClient.newRequest(url).method(method)
456 .header(HEADER_ACCEPT, CONTENT_TYPE)
457 .content(new StringContentProvider(StringUtil.defaultString(requestData)), CONTENT_TYPE);
460 final AccessTokenResponse accessTokenResponse = oAuthClientService.getAccessTokenResponse();
461 final String accessToken = (accessTokenResponse == null) ? null : accessTokenResponse.getAccessToken();
463 if (accessToken == null || accessToken.isEmpty()) {
464 throw new SmartherAuthorizationException(String
465 .format("No gateway accesstoken. Did you authorize smarther via %s ?", AUTH_SERVLET_ALIAS));
467 return requestWithRetry(call, accessToken);
469 } catch (SmartherGatewayException e) {
471 } catch (OAuthException | OAuthResponseException e) {
472 throw new SmartherAuthorizationException(e.getMessage(), e);
473 } catch (IOException e) {
474 throw new SmartherGatewayException(e.getMessage(), e);
479 * Manages a generic call to the API gateway using the given authorization access token.
480 * Retries the call if the access token is expired (refreshing it on behalf of further calls).
483 * the http call to make
485 * the authorization access token to use
487 * @return the response received from the API gateway
489 * @throws {@link OAuthException}
490 * in case of issues during the OAuth process
491 * @throws {@link OAuthResponseException}
492 * in case of response issues during the OAuth process
493 * @throws {@link IOException}
494 * in case of I/O issues of some sort
496 private ContentResponse requestWithRetry(final Function<HttpClient, Request> call, final String accessToken)
497 throws OAuthException, OAuthResponseException, IOException {
499 return this.connector.request(call, this.oAuthSubscriptionKey, BEARER + accessToken);
500 } catch (SmartherTokenExpiredException e) {
501 // Retry with new access token
503 return this.connector.request(call, this.oAuthSubscriptionKey,
504 BEARER + this.oAuthClientService.refreshToken().getAccessToken());
505 } catch (SmartherTokenExpiredException ex) {
506 // This should never happen in normal conditions
507 throw new SmartherAuthorizationException(String.format("Cannot refresh token: %s", ex.getMessage()));