]> git.basschouten.com Git - openhab-addons.git/blob
99cd23eca0306e5f5a165409c47fd4562ad32206
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2020 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.bticinosmarther.internal.api;
14
15 import static org.eclipse.jetty.http.HttpMethod.*;
16 import static org.openhab.binding.bticinosmarther.internal.SmartherBindingConstants.*;
17
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;
23 import java.util.Map;
24 import java.util.concurrent.ScheduledExecutorService;
25 import java.util.function.Function;
26
27 import javax.measure.quantity.Temperature;
28
29 import org.eclipse.jdt.annotation.NonNullByDefault;
30 import org.eclipse.jdt.annotation.Nullable;
31 import org.eclipse.jetty.client.HttpClient;
32 import org.eclipse.jetty.client.api.ContentResponse;
33 import org.eclipse.jetty.client.api.Request;
34 import org.eclipse.jetty.client.util.StringContentProvider;
35 import org.eclipse.jetty.http.HttpMethod;
36 import org.eclipse.jetty.http.HttpStatus;
37 import org.openhab.binding.bticinosmarther.internal.api.dto.Chronothermostat;
38 import org.openhab.binding.bticinosmarther.internal.api.dto.Enums.MeasureUnit;
39 import org.openhab.binding.bticinosmarther.internal.api.dto.Module;
40 import org.openhab.binding.bticinosmarther.internal.api.dto.ModuleStatus;
41 import org.openhab.binding.bticinosmarther.internal.api.dto.Plant;
42 import org.openhab.binding.bticinosmarther.internal.api.dto.Plants;
43 import org.openhab.binding.bticinosmarther.internal.api.dto.Program;
44 import org.openhab.binding.bticinosmarther.internal.api.dto.Subscription;
45 import org.openhab.binding.bticinosmarther.internal.api.dto.Topology;
46 import org.openhab.binding.bticinosmarther.internal.api.exception.SmartherAuthorizationException;
47 import org.openhab.binding.bticinosmarther.internal.api.exception.SmartherGatewayException;
48 import org.openhab.binding.bticinosmarther.internal.api.exception.SmartherTokenExpiredException;
49 import org.openhab.binding.bticinosmarther.internal.model.ModuleSettings;
50 import org.openhab.binding.bticinosmarther.internal.util.ModelUtil;
51 import org.openhab.binding.bticinosmarther.internal.util.StringUtil;
52 import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
53 import org.openhab.core.auth.client.oauth2.OAuthClientService;
54 import org.openhab.core.auth.client.oauth2.OAuthException;
55 import org.openhab.core.auth.client.oauth2.OAuthResponseException;
56 import org.openhab.core.library.types.QuantityType;
57 import org.openhab.core.library.unit.SIUnits;
58 import org.slf4j.Logger;
59 import org.slf4j.LoggerFactory;
60
61 import com.google.gson.JsonSyntaxException;
62 import com.google.gson.reflect.TypeToken;
63
64 /**
65  * The {@code SmartherApi} class is used to communicate with the BTicino/Legrand API gateway.
66  *
67  * @author Fabio Possieri - Initial contribution
68  */
69 @NonNullByDefault
70 public class SmartherApi {
71
72     private static final String CONTENT_TYPE = "application/json";
73     private static final String BEARER = "Bearer ";
74
75     // API gateway request headers
76     private static final String HEADER_ACCEPT = "Accept";
77     // API gateway request attributes
78     private static final String ATTR_FUNCTION = "function";
79     private static final String ATTR_MODE = "mode";
80     private static final String ATTR_PROGRAMS = "programs";
81     private static final String ATTR_NUMBER = "number";
82     private static final String ATTR_SETPOINT = "setPoint";
83     private static final String ATTR_VALUE = "value";
84     private static final String ATTR_UNIT = "unit";
85     private static final String ATTR_ACTIVATION_TIME = "activationTime";
86     private static final String ATTR_ENDPOINT_URL = "EndPointUrl";
87     // API gateway operation paths
88     private static final String PATH_PLANTS = "/plants";
89     private static final String PATH_TOPOLOGY = PATH_PLANTS + "/%s/topology";
90     private static final String PATH_MODULE = "/chronothermostat/thermoregulation/addressLocation/plants/%s/modules/parameter/id/value/%s";
91     private static final String PATH_PROGRAMS = "/programlist";
92     private static final String PATH_SUBSCRIPTIONS = "/subscription";
93     private static final String PATH_SUBSCRIBE = PATH_PLANTS + "/%s/subscription";
94     private static final String PATH_UNSUBSCRIBE = PATH_SUBSCRIBE + "/%s";
95
96     private final Logger logger = LoggerFactory.getLogger(SmartherApi.class);
97
98     private final OAuthClientService oAuthClientService;
99     private final String oAuthSubscriptionKey;
100     private final SmartherApiConnector connector;
101
102     /**
103      * Constructs a {@code SmartherApi} to the API gateway with the specified OAuth2 attributes (subscription key and
104      * client service), scheduler service and http client.
105      *
106      * @param clientService
107      *            the OAuth2 authorization client service to be used
108      * @param subscriptionKey
109      *            the OAuth2 subscription key to be used with the given client service
110      * @param scheduler
111      *            the scheduler to be used to reschedule calls when rate limit exceeded or call not succeeded
112      * @param httpClient
113      *            the http client to be used to make http calls to the API gateway
114      */
115     public SmartherApi(final OAuthClientService clientService, final String subscriptionKey,
116             final ScheduledExecutorService scheduler, final HttpClient httpClient) {
117         this.oAuthClientService = clientService;
118         this.oAuthSubscriptionKey = subscriptionKey;
119         this.connector = new SmartherApiConnector(scheduler, httpClient);
120     }
121
122     /**
123      * Returns the plants registered under the Smarther account the bridge has been configured with.
124      *
125      * @return the list of registered plants, or an empty {@link List} in case of no plants found
126      *
127      * @throws {@link SmartherGatewayException}
128      *             in case of communication issues with the API gateway
129      */
130     public List<Plant> getPlants() throws SmartherGatewayException {
131         try {
132             final ContentResponse response = requestBasic(GET, PATH_PLANTS);
133             if (response.getStatus() == HttpStatus.NO_CONTENT_204) {
134                 return new ArrayList<>();
135             } else {
136                 return ModelUtil.gsonInstance().fromJson(response.getContentAsString(), Plants.class).getPlants();
137             }
138         } catch (JsonSyntaxException e) {
139             throw new SmartherGatewayException(e.getMessage());
140         }
141     }
142
143     /**
144      * Returns the chronothermostat modules registered in the given plant.
145      *
146      * @param plantId
147      *            the identifier of the plant
148      *
149      * @return the list of registered modules, or an empty {@link List} in case the plant contains no module
150      *
151      * @throws {@link SmartherGatewayException}
152      *             in case of communication issues with the API gateway
153      */
154     public List<Module> getPlantModules(String plantId) throws SmartherGatewayException {
155         try {
156             final ContentResponse response = requestBasic(GET, String.format(PATH_TOPOLOGY, plantId));
157             final Topology topology = ModelUtil.gsonInstance().fromJson(response.getContentAsString(), Topology.class);
158             return topology.getModules();
159         } catch (JsonSyntaxException e) {
160             throw new SmartherGatewayException(e.getMessage());
161         }
162     }
163
164     /**
165      * Returns the current status of a given chronothermostat module.
166      *
167      * @param plantId
168      *            the identifier of the plant
169      * @param moduleId
170      *            the identifier of the chronothermostat module inside the plant
171      *
172      * @return the current status of the chronothermostat module
173      *
174      * @throws {@link SmartherGatewayException}
175      *             in case of communication issues with the API gateway
176      */
177     public ModuleStatus getModuleStatus(String plantId, String moduleId) throws SmartherGatewayException {
178         try {
179             final ContentResponse response = requestModule(GET, plantId, moduleId, null);
180             return ModelUtil.gsonInstance().fromJson(response.getContentAsString(), ModuleStatus.class);
181         } catch (JsonSyntaxException e) {
182             throw new SmartherGatewayException(e.getMessage());
183         }
184     }
185
186     /**
187      * Sends new settings to be applied to a given chronothermostat module.
188      *
189      * @param settings
190      *            the module settings to be applied
191      *
192      * @return {@code true} if the settings have been successfully applied, {@code false} otherwise
193      *
194      * @throws {@link SmartherGatewayException}
195      *             in case of communication issues with the API gateway
196      */
197     public boolean setModuleStatus(ModuleSettings settings) throws SmartherGatewayException {
198         // Prepare request payload
199         Map<String, Object> rootMap = new IdentityHashMap<>();
200         rootMap.put(ATTR_FUNCTION, settings.getFunction().getValue());
201         rootMap.put(ATTR_MODE, settings.getMode().getValue());
202         switch (settings.getMode()) {
203             case AUTOMATIC:
204                 // {"function":"heating","mode":"automatic","programs":[{"number":0}]}
205                 Map<String, Integer> programMap = new IdentityHashMap<String, Integer>();
206                 programMap.put(ATTR_NUMBER, Integer.valueOf(settings.getProgram()));
207                 List<Map<String, Integer>> programsList = new ArrayList<>();
208                 programsList.add(programMap);
209                 rootMap.put(ATTR_PROGRAMS, programsList);
210                 break;
211             case MANUAL:
212                 // {"function":"heating","mode":"manual","setPoint":{"value":0.0,"unit":"C"},"activationTime":"X"}
213                 QuantityType<Temperature> newTemperature = settings.getSetPointTemperature(SIUnits.CELSIUS);
214                 if (newTemperature == null) {
215                     throw new SmartherGatewayException("Invalid temperature unit transformation");
216                 }
217                 Map<String, Object> setPointMap = new IdentityHashMap<String, Object>();
218                 setPointMap.put(ATTR_VALUE, newTemperature.doubleValue());
219                 setPointMap.put(ATTR_UNIT, MeasureUnit.CELSIUS.getValue());
220                 rootMap.put(ATTR_SETPOINT, setPointMap);
221                 rootMap.put(ATTR_ACTIVATION_TIME, settings.getActivationTime());
222                 break;
223             case BOOST:
224                 // {"function":"heating","mode":"boost","activationTime":"X"}
225                 rootMap.put(ATTR_ACTIVATION_TIME, settings.getActivationTime());
226                 break;
227             case OFF:
228                 // {"function":"heating","mode":"off"}
229                 break;
230             case PROTECTION:
231                 // {"function":"heating","mode":"protection"}
232                 break;
233         }
234         final String jsonPayload = ModelUtil.gsonInstance().toJson(rootMap);
235
236         // Send request to server
237         final ContentResponse response = requestModule(POST, settings.getPlantId(), settings.getModuleId(),
238                 jsonPayload);
239         return (response.getStatus() == HttpStatus.OK_200);
240     }
241
242     /**
243      * Returns the automatic mode programs registered for the given chronothermostat module.
244      *
245      * @param plantId
246      *            the identifier of the plant
247      * @param moduleId
248      *            the identifier of the chronothermostat module inside the plant
249      *
250      * @return the list of registered programs, or an empty {@link List} in case of no programs found
251      *
252      * @throws {@link SmartherGatewayException}
253      *             in case of communication issues with the API gateway
254      */
255     public List<Program> getModulePrograms(String plantId, String moduleId) throws SmartherGatewayException {
256         try {
257             final ContentResponse response = requestModule(GET, plantId, moduleId, PATH_PROGRAMS, null);
258             final ModuleStatus moduleStatus = ModelUtil.gsonInstance().fromJson(response.getContentAsString(),
259                     ModuleStatus.class);
260
261             final Chronothermostat chronothermostat = moduleStatus.toChronothermostat();
262             return (chronothermostat != null) ? chronothermostat.getPrograms() : Collections.emptyList();
263         } catch (JsonSyntaxException e) {
264             throw new SmartherGatewayException(e.getMessage());
265         }
266     }
267
268     /**
269      * Returns the subscriptions registered to the C2C Webhook, where modules status notifications are currently sent
270      * for all the plants.
271      *
272      * @return the list of registered subscriptions, or an empty {@link List} in case of no subscriptions found
273      *
274      * @throws {@link SmartherGatewayException}
275      *             in case of communication issues with the API gateway
276      */
277     public List<Subscription> getSubscriptions() throws SmartherGatewayException {
278         try {
279             final ContentResponse response = requestBasic(GET, PATH_SUBSCRIPTIONS);
280             if (response.getStatus() == HttpStatus.NO_CONTENT_204) {
281                 return new ArrayList<>();
282             } else {
283                 return ModelUtil.gsonInstance().fromJson(response.getContentAsString(),
284                         new TypeToken<List<Subscription>>() {
285                         }.getType());
286             }
287         } catch (JsonSyntaxException e) {
288             throw new SmartherGatewayException(e.getMessage());
289         }
290     }
291
292     /**
293      * Subscribes a plant to the C2C Webhook to start receiving modules status notifications.
294      *
295      * @param plantId
296      *            the identifier of the plant to be subscribed
297      * @param notificationUrl
298      *            the url notifications will have to be sent to for the given plant
299      *
300      * @return the identifier this subscription has been registered under
301      *
302      * @throws {@link SmartherGatewayException}
303      *             in case of communication issues with the API gateway
304      */
305     public String subscribePlant(String plantId, String notificationUrl) throws SmartherGatewayException {
306         try {
307             // Prepare request payload
308             Map<String, Object> rootMap = new IdentityHashMap<String, Object>();
309             rootMap.put(ATTR_ENDPOINT_URL, notificationUrl);
310             final String jsonPayload = ModelUtil.gsonInstance().toJson(rootMap);
311             // Send request to server
312             final ContentResponse response = requestBasic(POST, String.format(PATH_SUBSCRIBE, plantId), jsonPayload);
313             // Handle response payload
314             final Subscription subscription = ModelUtil.gsonInstance().fromJson(response.getContentAsString(),
315                     Subscription.class);
316             return subscription.getSubscriptionId();
317         } catch (JsonSyntaxException e) {
318             throw new SmartherGatewayException(e.getMessage());
319         }
320     }
321
322     /**
323      * Unsubscribes a plant from the C2C Webhook to stop receiving modules status notifications.
324      *
325      * @param plantId
326      *            the identifier of the plant to be unsubscribed
327      * @param subscriptionId
328      *            the identifier of the subscription to be removed for the given plant
329      *
330      * @return {@code true} if the plant is successfully unsubscribed, {@code false} otherwise
331      *
332      * @throws {@link SmartherGatewayException}
333      *             in case of communication issues with the API gateway
334      */
335     public boolean unsubscribePlant(String plantId, String subscriptionId) throws SmartherGatewayException {
336         final ContentResponse response = requestBasic(DELETE, String.format(PATH_UNSUBSCRIBE, plantId, subscriptionId));
337         return (response.getStatus() == HttpStatus.OK_200);
338     }
339
340     // ===========================================================================
341     //
342     // Internal API call handling methods
343     //
344     // ===========================================================================
345
346     /**
347      * Calls the API gateway with the given http method, request url and actual data.
348      *
349      * @param method
350      *            the http method to make the call with
351      * @param url
352      *            the API operation url to call
353      * @param requestData
354      *            the actual data to send in the request body, may be {@code null}
355      *
356      * @return the response received from the API gateway
357      *
358      * @throws {@link SmartherGatewayException}
359      *             in case of communication issues with the API gateway
360      */
361     private ContentResponse requestBasic(HttpMethod method, String url, @Nullable String requestData)
362             throws SmartherGatewayException {
363         return request(method, SMARTHER_API_URL + url, requestData);
364     }
365
366     /**
367      * Calls the API gateway with the given http method and request url.
368      *
369      * @param method
370      *            the http method to make the call with
371      * @param url
372      *            the API operation url to call
373      *
374      * @return the response received from the API gateway
375      *
376      * @throws {@link SmartherGatewayException}
377      *             in case of communication issues with the API gateway
378      */
379     private ContentResponse requestBasic(HttpMethod method, String url) throws SmartherGatewayException {
380         return requestBasic(method, url, null);
381     }
382
383     /**
384      * Calls the API gateway with the given http method, plant id, module id, request path and actual data.
385      *
386      * @param method
387      *            the http method to make the call with
388      * @param plantId
389      *            the identifier of the plant to use
390      * @param moduleId
391      *            the identifier of the module to use
392      * @param path
393      *            the API operation relative path to call, may be {@code null}
394      * @param requestData
395      *            the actual data to send in the request body, may be {@code null}
396      *
397      * @return the response received from the API gateway
398      *
399      * @throws {@link SmartherGatewayException}
400      *             in case of communication issues with the API gateway
401      */
402     private ContentResponse requestModule(HttpMethod method, String plantId, String moduleId, @Nullable String path,
403             @Nullable String requestData) throws SmartherGatewayException {
404         final String url = String.format(PATH_MODULE, plantId, moduleId) + StringUtil.defaultString(path);
405         return requestBasic(method, url, requestData);
406     }
407
408     /**
409      * Calls the API gateway with the given http method, plant id, module id and actual data.
410      *
411      * @param method
412      *            the http method to make the call with
413      * @param plantId
414      *            the identifier of the plant to use
415      * @param moduleId
416      *            the identifier of the module to use
417      * @param requestData
418      *            the actual data to send in the request body, may be {@code null}
419      *
420      * @return the response received from the API gateway
421      *
422      * @throws {@link SmartherGatewayException}
423      *             in case of communication issues with the API gateway
424      */
425     private ContentResponse requestModule(HttpMethod method, String plantId, String moduleId,
426             @Nullable String requestData) throws SmartherGatewayException {
427         return requestModule(method, plantId, moduleId, null, requestData);
428     }
429
430     /**
431      * Calls the API gateway with the given http method, request url and actual data.
432      *
433      * @param method
434      *            the http method to make the call with
435      * @param url
436      *            the API operation url to call
437      * @param requestData
438      *            the actual data to send in the request body, may be {@code null}
439      *
440      * @return the response received from the API gateway
441      *
442      * @throws {@link SmartherGatewayException}
443      *             in case of communication issues with the API gateway
444      */
445     private ContentResponse request(HttpMethod method, String url, @Nullable String requestData)
446             throws SmartherGatewayException {
447         logger.debug("Request: ({}) {} - {}", method, url, StringUtil.defaultString(requestData));
448         Function<HttpClient, Request> call = httpClient -> httpClient.newRequest(url).method(method)
449                 .header(HEADER_ACCEPT, CONTENT_TYPE)
450                 .content(new StringContentProvider(StringUtil.defaultString(requestData)), CONTENT_TYPE);
451
452         try {
453             final AccessTokenResponse accessTokenResponse = oAuthClientService.getAccessTokenResponse();
454             final String accessToken = (accessTokenResponse == null) ? null : accessTokenResponse.getAccessToken();
455
456             if (accessToken == null || accessToken.isEmpty()) {
457                 throw new SmartherAuthorizationException(String
458                         .format("No gateway accesstoken. Did you authorize smarther via %s ?", AUTH_SERVLET_ALIAS));
459             } else {
460                 return requestWithRetry(call, accessToken);
461             }
462         } catch (SmartherGatewayException e) {
463             throw e;
464         } catch (OAuthException | OAuthResponseException e) {
465             throw new SmartherAuthorizationException(e.getMessage(), e);
466         } catch (IOException e) {
467             throw new SmartherGatewayException(e.getMessage(), e);
468         }
469     }
470
471     /**
472      * Manages a generic call to the API gateway using the given authorization access token.
473      * Retries the call if the access token is expired (refreshing it on behalf of further calls).
474      *
475      * @param call
476      *            the http call to make
477      * @param accessToken
478      *            the authorization access token to use
479      *
480      * @return the response received from the API gateway
481      *
482      * @throws {@link OAuthException}
483      *             in case of issues during the OAuth process
484      * @throws {@link OAuthResponseException}
485      *             in case of response issues during the OAuth process
486      * @throws {@link IOException}
487      *             in case of I/O issues of some sort
488      */
489     private ContentResponse requestWithRetry(final Function<HttpClient, Request> call, final String accessToken)
490             throws OAuthException, OAuthResponseException, IOException {
491         try {
492             return this.connector.request(call, this.oAuthSubscriptionKey, BEARER + accessToken);
493         } catch (SmartherTokenExpiredException e) {
494             // Retry with new access token
495             try {
496                 return this.connector.request(call, this.oAuthSubscriptionKey,
497                         BEARER + this.oAuthClientService.refreshToken().getAccessToken());
498             } catch (SmartherTokenExpiredException ex) {
499                 // This should never happen in normal conditions
500                 throw new SmartherAuthorizationException(String.format("Cannot refresh token: %s", ex.getMessage()));
501             }
502         }
503     }
504 }