]> git.basschouten.com Git - openhab-addons.git/blob
16d2423953ba6d862b18ceb562dbc147705b0dec
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 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.Objects;
25 import java.util.concurrent.ScheduledExecutorService;
26 import java.util.function.Function;
27
28 import javax.measure.quantity.Temperature;
29
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;
61
62 import com.google.gson.JsonSyntaxException;
63 import com.google.gson.reflect.TypeToken;
64
65 /**
66  * The {@code SmartherApi} class is used to communicate with the BTicino/Legrand API gateway.
67  *
68  * @author Fabio Possieri - Initial contribution
69  */
70 @NonNullByDefault
71 public class SmartherApi {
72
73     private static final String CONTENT_TYPE = "application/json";
74     private static final String BEARER = "Bearer ";
75
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";
96
97     private final Logger logger = LoggerFactory.getLogger(SmartherApi.class);
98
99     private final OAuthClientService oAuthClientService;
100     private final String oAuthSubscriptionKey;
101     private final SmartherApiConnector connector;
102
103     /**
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.
106      *
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
111      * @param scheduler
112      *            the scheduler to be used to reschedule calls when rate limit exceeded or call not succeeded
113      * @param httpClient
114      *            the http client to be used to make http calls to the API gateway
115      */
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);
121     }
122
123     /**
124      * Returns the plants registered under the Smarther account the bridge has been configured with.
125      *
126      * @return the list of registered plants, or an empty {@link List} in case of no plants found
127      *
128      * @throws {@link SmartherGatewayException}
129      *             in case of communication issues with the API gateway
130      */
131     public List<Plant> getPlants() throws SmartherGatewayException {
132         try {
133             final ContentResponse response = requestBasic(GET, PATH_PLANTS);
134             if (response.getStatus() == HttpStatus.NO_CONTENT_204) {
135                 return new ArrayList<>();
136             } else {
137                 return ModelUtil.gsonInstance().fromJson(response.getContentAsString(), Plants.class).getPlants();
138             }
139         } catch (JsonSyntaxException e) {
140             throw new SmartherGatewayException(e.getMessage());
141         }
142     }
143
144     /**
145      * Returns the chronothermostat modules registered in the given plant.
146      *
147      * @param plantId
148      *            the identifier of the plant
149      *
150      * @return the list of registered modules, or an empty {@link List} in case the plant contains no module
151      *
152      * @throws {@link SmartherGatewayException}
153      *             in case of communication issues with the API gateway
154      */
155     public List<Module> getPlantModules(String plantId) throws SmartherGatewayException {
156         try {
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());
162         }
163     }
164
165     /**
166      * Returns the current status of a given chronothermostat module.
167      *
168      * @param plantId
169      *            the identifier of the plant
170      * @param moduleId
171      *            the identifier of the chronothermostat module inside the plant
172      *
173      * @return the current status of the chronothermostat module
174      *
175      * @throws {@link SmartherGatewayException}
176      *             in case of communication issues with the API gateway
177      */
178     public ModuleStatus getModuleStatus(String plantId, String moduleId) throws SmartherGatewayException {
179         try {
180             final ContentResponse response = requestModule(GET, plantId, moduleId, null);
181             ModuleStatus moduleStatus = ModelUtil.gsonInstance().fromJson(response.getContentAsString(),
182                     ModuleStatus.class);
183             return Objects.requireNonNull(moduleStatus);
184         } catch (JsonSyntaxException e) {
185             throw new SmartherGatewayException(e.getMessage());
186         }
187     }
188
189     /**
190      * Sends new settings to be applied to a given chronothermostat module.
191      *
192      * @param settings
193      *            the module settings to be applied
194      *
195      * @return {@code true} if the settings have been successfully applied, {@code false} otherwise
196      *
197      * @throws {@link SmartherGatewayException}
198      *             in case of communication issues with the API gateway
199      */
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()) {
206             case AUTOMATIC:
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);
213                 break;
214             case MANUAL:
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");
219                 }
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());
225                 break;
226             case BOOST:
227                 // {"function":"heating","mode":"boost","activationTime":"X"}
228                 rootMap.put(ATTR_ACTIVATION_TIME, settings.getActivationTime());
229                 break;
230             case OFF:
231                 // {"function":"heating","mode":"off"}
232                 break;
233             case PROTECTION:
234                 // {"function":"heating","mode":"protection"}
235                 break;
236         }
237         final String jsonPayload = ModelUtil.gsonInstance().toJson(rootMap);
238
239         // Send request to server
240         final ContentResponse response = requestModule(POST, settings.getPlantId(), settings.getModuleId(),
241                 jsonPayload);
242         return (response.getStatus() == HttpStatus.OK_200);
243     }
244
245     /**
246      * Returns the automatic mode programs registered for the given chronothermostat module.
247      *
248      * @param plantId
249      *            the identifier of the plant
250      * @param moduleId
251      *            the identifier of the chronothermostat module inside the plant
252      *
253      * @return the list of registered programs, or an empty {@link List} in case of no programs found
254      *
255      * @throws {@link SmartherGatewayException}
256      *             in case of communication issues with the API gateway
257      */
258     public List<Program> getModulePrograms(String plantId, String moduleId) throws SmartherGatewayException {
259         try {
260             final ContentResponse response = requestModule(GET, plantId, moduleId, PATH_PROGRAMS, null);
261             final ModuleStatus moduleStatus = ModelUtil.gsonInstance().fromJson(response.getContentAsString(),
262                     ModuleStatus.class);
263
264             final Chronothermostat chronothermostat = moduleStatus.toChronothermostat();
265             return (chronothermostat != null) ? chronothermostat.getPrograms() : Collections.emptyList();
266         } catch (JsonSyntaxException e) {
267             throw new SmartherGatewayException(e.getMessage());
268         }
269     }
270
271     /**
272      * Returns the subscriptions registered to the C2C Webhook, where modules status notifications are currently sent
273      * for all the plants.
274      *
275      * @return the list of registered subscriptions, or an empty {@link List} in case of no subscriptions found
276      *
277      * @throws {@link SmartherGatewayException}
278      *             in case of communication issues with the API gateway
279      */
280     public List<Subscription> getSubscriptions() throws SmartherGatewayException {
281         try {
282             final ContentResponse response = requestBasic(GET, PATH_SUBSCRIPTIONS);
283             if (response.getStatus() == HttpStatus.NO_CONTENT_204) {
284                 return new ArrayList<>();
285             } else {
286                 List<Subscription> subscriptions = ModelUtil.gsonInstance().fromJson(response.getContentAsString(),
287                         new TypeToken<List<Subscription>>() {
288                         }.getType());
289                 if (subscriptions == null) {
290                     throw new SmartherGatewayException("fromJson returned null");
291                 }
292                 return subscriptions;
293             }
294         } catch (JsonSyntaxException e) {
295             throw new SmartherGatewayException(e.getMessage());
296         }
297     }
298
299     /**
300      * Subscribes a plant to the C2C Webhook to start receiving modules status notifications.
301      *
302      * @param plantId
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
306      *
307      * @return the identifier this subscription has been registered under
308      *
309      * @throws {@link SmartherGatewayException}
310      *             in case of communication issues with the API gateway
311      */
312     public String subscribePlant(String plantId, String notificationUrl) throws SmartherGatewayException {
313         try {
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(),
322                     Subscription.class);
323             return subscription.getSubscriptionId();
324         } catch (JsonSyntaxException e) {
325             throw new SmartherGatewayException(e.getMessage());
326         }
327     }
328
329     /**
330      * Unsubscribes a plant from the C2C Webhook to stop receiving modules status notifications.
331      *
332      * @param plantId
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
336      *
337      * @return {@code true} if the plant is successfully unsubscribed, {@code false} otherwise
338      *
339      * @throws {@link SmartherGatewayException}
340      *             in case of communication issues with the API gateway
341      */
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);
345     }
346
347     // ===========================================================================
348     //
349     // Internal API call handling methods
350     //
351     // ===========================================================================
352
353     /**
354      * Calls the API gateway with the given http method, request url and actual data.
355      *
356      * @param method
357      *            the http method to make the call with
358      * @param url
359      *            the API operation url to call
360      * @param requestData
361      *            the actual data to send in the request body, may be {@code null}
362      *
363      * @return the response received from the API gateway
364      *
365      * @throws {@link SmartherGatewayException}
366      *             in case of communication issues with the API gateway
367      */
368     private ContentResponse requestBasic(HttpMethod method, String url, @Nullable String requestData)
369             throws SmartherGatewayException {
370         return request(method, SMARTHER_API_URL + url, requestData);
371     }
372
373     /**
374      * Calls the API gateway with the given http method and request url.
375      *
376      * @param method
377      *            the http method to make the call with
378      * @param url
379      *            the API operation url to call
380      *
381      * @return the response received from the API gateway
382      *
383      * @throws {@link SmartherGatewayException}
384      *             in case of communication issues with the API gateway
385      */
386     private ContentResponse requestBasic(HttpMethod method, String url) throws SmartherGatewayException {
387         return requestBasic(method, url, null);
388     }
389
390     /**
391      * Calls the API gateway with the given http method, plant id, module id, request path and actual data.
392      *
393      * @param method
394      *            the http method to make the call with
395      * @param plantId
396      *            the identifier of the plant to use
397      * @param moduleId
398      *            the identifier of the module to use
399      * @param path
400      *            the API operation relative path to call, may be {@code null}
401      * @param requestData
402      *            the actual data to send in the request body, may be {@code null}
403      *
404      * @return the response received from the API gateway
405      *
406      * @throws {@link SmartherGatewayException}
407      *             in case of communication issues with the API gateway
408      */
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);
413     }
414
415     /**
416      * Calls the API gateway with the given http method, plant id, module id and actual data.
417      *
418      * @param method
419      *            the http method to make the call with
420      * @param plantId
421      *            the identifier of the plant to use
422      * @param moduleId
423      *            the identifier of the module to use
424      * @param requestData
425      *            the actual data to send in the request body, may be {@code null}
426      *
427      * @return the response received from the API gateway
428      *
429      * @throws {@link SmartherGatewayException}
430      *             in case of communication issues with the API gateway
431      */
432     private ContentResponse requestModule(HttpMethod method, String plantId, String moduleId,
433             @Nullable String requestData) throws SmartherGatewayException {
434         return requestModule(method, plantId, moduleId, null, requestData);
435     }
436
437     /**
438      * Calls the API gateway with the given http method, request url and actual data.
439      *
440      * @param method
441      *            the http method to make the call with
442      * @param url
443      *            the API operation url to call
444      * @param requestData
445      *            the actual data to send in the request body, may be {@code null}
446      *
447      * @return the response received from the API gateway
448      *
449      * @throws {@link SmartherGatewayException}
450      *             in case of communication issues with the API gateway
451      */
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);
458
459         try {
460             final AccessTokenResponse accessTokenResponse = oAuthClientService.getAccessTokenResponse();
461             final String accessToken = (accessTokenResponse == null) ? null : accessTokenResponse.getAccessToken();
462
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));
466             } else {
467                 return requestWithRetry(call, accessToken);
468             }
469         } catch (SmartherGatewayException e) {
470             throw e;
471         } catch (OAuthException | OAuthResponseException e) {
472             throw new SmartherAuthorizationException(e.getMessage(), e);
473         } catch (IOException e) {
474             throw new SmartherGatewayException(e.getMessage(), e);
475         }
476     }
477
478     /**
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).
481      *
482      * @param call
483      *            the http call to make
484      * @param accessToken
485      *            the authorization access token to use
486      *
487      * @return the response received from the API gateway
488      *
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
495      */
496     private ContentResponse requestWithRetry(final Function<HttpClient, Request> call, final String accessToken)
497             throws OAuthException, OAuthResponseException, IOException {
498         try {
499             return this.connector.request(call, this.oAuthSubscriptionKey, BEARER + accessToken);
500         } catch (SmartherTokenExpiredException e) {
501             // Retry with new access token
502             try {
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()));
508             }
509         }
510     }
511 }