]> git.basschouten.com Git - openhab-addons.git/blob
a9aeda36edc5834582d6052ee3abced35384ccac
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2022 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.elroconnects.internal.handler;
14
15 import java.lang.reflect.Type;
16 import java.net.SocketTimeoutException;
17 import java.net.URI;
18 import java.util.Collection;
19 import java.util.Collections;
20 import java.util.HashMap;
21 import java.util.List;
22 import java.util.Map;
23 import java.util.concurrent.CompletableFuture;
24 import java.util.concurrent.ExecutionException;
25 import java.util.concurrent.ScheduledFuture;
26 import java.util.concurrent.TimeUnit;
27 import java.util.concurrent.TimeoutException;
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.HttpResponse;
33 import org.eclipse.jetty.client.api.Request;
34 import org.eclipse.jetty.client.api.Result;
35 import org.eclipse.jetty.client.util.BufferingResponseListener;
36 import org.eclipse.jetty.client.util.StringContentProvider;
37 import org.eclipse.jetty.http.HttpHeader;
38 import org.eclipse.jetty.http.HttpMethod;
39 import org.openhab.binding.elroconnects.internal.devices.ElroConnectsConnector;
40 import org.openhab.binding.elroconnects.internal.discovery.ElroConnectsBridgeDiscoveryService;
41 import org.openhab.core.thing.Bridge;
42 import org.openhab.core.thing.ChannelUID;
43 import org.openhab.core.thing.ThingStatus;
44 import org.openhab.core.thing.ThingStatusDetail;
45 import org.openhab.core.thing.binding.BaseBridgeHandler;
46 import org.openhab.core.thing.binding.ThingHandlerService;
47 import org.openhab.core.types.Command;
48 import org.slf4j.Logger;
49 import org.slf4j.LoggerFactory;
50
51 import com.google.gson.Gson;
52 import com.google.gson.JsonParseException;
53 import com.google.gson.reflect.TypeToken;
54
55 /**
56  * The {@link ElroConnectsAccountHandler} is the bridge handler responsible to for connecting the the ELRO Connects
57  * cloud service and retrieving the defined K1 hubs.
58  *
59  * @author Mark Herwege - Initial contribution
60  */
61 @NonNullByDefault
62 public class ElroConnectsAccountHandler extends BaseBridgeHandler {
63
64     private final Logger logger = LoggerFactory.getLogger(ElroConnectsAccountHandler.class);
65
66     private static final String ELRO_CLOUD_LOGIN_URL = "https://uaa-openapi.hekreu.me/login";
67     private static final String ELRO_CLOUD_URL = "https://user-openapi.hekreu.me/device";
68     private static final String ELRO_PID = "01288154146"; // ELRO Connects Enterprise PID on hekr cloud
69
70     private static final int TIMEOUT_MS = 2500;
71     private static final int REFRESH_INTERVAL_S = 300;
72
73     private boolean enableBackgroundDiscovery = true;
74
75     private volatile @Nullable ScheduledFuture<?> pollingJob;
76     private final HttpClient client;
77
78     private Gson gson = new Gson();
79     private Type loginType = new TypeToken<Map<String, String>>() {
80     }.getType();
81     private Type deviceListType = new TypeToken<List<ElroConnectsConnector>>() {
82     }.getType();
83
84     private final Map<String, String> login = new HashMap<>();
85     private String loginJson = "";
86     private volatile @Nullable String accessToken;
87
88     private volatile boolean retry = false; // Flag for retrying login when token expired during poll, prevents multiple
89                                             // recursive retries
90
91     private volatile Map<String, ElroConnectsConnector> devices = new HashMap<>();
92
93     private @Nullable ElroConnectsBridgeDiscoveryService discoveryService = null;
94
95     public ElroConnectsAccountHandler(Bridge bridge, HttpClient client) {
96         super(bridge);
97         this.client = client;
98         login.put("pid", ELRO_PID);
99         login.put("clientType", "WEB");
100     }
101
102     @Override
103     public void initialize() {
104         accessToken = null;
105
106         ElroConnectsAccountConfiguration config = getConfigAs(ElroConnectsAccountConfiguration.class);
107         String username = config.username;
108         String password = config.password;
109         enableBackgroundDiscovery = config.enableBackgroundDiscovery;
110
111         if ((username == null) || username.isEmpty()) {
112             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING, "@text/offline.no-username");
113             return;
114         }
115         if ((password == null) || password.isEmpty()) {
116             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING, "@text/offline.no-password");
117             return;
118         }
119
120         login.put("username", username);
121         login.put("password", password);
122         loginJson = gson.toJson(login);
123
124         // If background discovery is enabled, start polling (will do login first), else only login to take the thing
125         // online if successful
126         if (enableBackgroundDiscovery) {
127             startPolling();
128         } else {
129             scheduler.execute(this::login);
130         }
131     }
132
133     @Override
134     public void dispose() {
135         logger.debug("Handler disposed");
136         stopPolling();
137         super.dispose();
138     }
139
140     private void startPolling() {
141         final ScheduledFuture<?> localRefreshJob = this.pollingJob;
142         if (localRefreshJob == null || localRefreshJob.isCancelled()) {
143             logger.debug("Start polling");
144             pollingJob = scheduler.scheduleWithFixedDelay(this::poll, 0, REFRESH_INTERVAL_S, TimeUnit.SECONDS);
145         }
146
147         ElroConnectsBridgeDiscoveryService service = this.discoveryService;
148         if (service != null) {
149             service.startBackgroundDiscovery();
150         }
151     }
152
153     private void stopPolling() {
154         ElroConnectsBridgeDiscoveryService service = this.discoveryService;
155         if (service != null) {
156             service.stopBackgroundDiscovery();
157         }
158
159         final ScheduledFuture<?> localPollingJob = this.pollingJob;
160         if (localPollingJob != null && !localPollingJob.isCancelled()) {
161             logger.debug("Stop polling");
162             localPollingJob.cancel(true);
163             pollingJob = null;
164         }
165     }
166
167     private void poll() {
168         logger.debug("Polling");
169
170         // when access token not yet received or expired, try to login first
171         if (accessToken == null) {
172             login();
173         }
174         if (accessToken == null) {
175             return;
176         }
177
178         try {
179             getControllers().handle((devicesList, accountException) -> {
180                 if (devicesList != null) {
181                     logger.trace("deviceList response: {}", devicesList);
182
183                     List<ElroConnectsConnector> response = null;
184                     try {
185                         response = gson.fromJson(devicesList, deviceListType);
186                     } catch (JsonParseException parseException) {
187                         logger.warn("Parsing failed: {}", parseException.getMessage());
188                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
189                                 "@text/offline.request-failed");
190                         return null;
191                     }
192                     Map<String, ElroConnectsConnector> devices = new HashMap<>();
193                     if (response != null) {
194                         response.forEach(d -> devices.put(d.getDevTid(), d));
195                     }
196                     this.devices = devices;
197                     updateStatus(ThingStatus.ONLINE);
198                 } else {
199                     if (accountException == null) {
200                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
201                     } else {
202                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
203                                 accountException.getLocalizedMessage());
204                     }
205                 }
206
207                 return null;
208             }).get();
209         } catch (InterruptedException e) {
210             updateStatus(ThingStatus.OFFLINE);
211             Thread.currentThread().interrupt();
212         } catch (ExecutionException e) {
213             logger.debug("Poll exception", e);
214             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
215         }
216     }
217
218     private void login() {
219         logger.debug("Login");
220         try {
221             getAccessToken().handle((accessToken, accountException) -> {
222                 this.accessToken = accessToken;
223                 if (accessToken != null) {
224                     updateStatus(ThingStatus.ONLINE);
225                 } else {
226                     if (accountException == null) {
227                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
228                     } else {
229                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
230                                 accountException.getLocalizedMessage());
231                     }
232                 }
233
234                 return null;
235             }).get();
236         } catch (InterruptedException e) {
237             updateStatus(ThingStatus.OFFLINE);
238             Thread.currentThread().interrupt();
239         } catch (ExecutionException e) {
240             logger.debug("Login exception", e);
241             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
242         }
243     }
244
245     private CompletableFuture<@Nullable String> getAccessToken() {
246         CompletableFuture<@Nullable String> f = new CompletableFuture<>();
247         Request request = client.newRequest(URI.create(ELRO_CLOUD_LOGIN_URL));
248
249         request.method(HttpMethod.POST).content(new StringContentProvider(loginJson), "application/json")
250                 .timeout(TIMEOUT_MS, TimeUnit.MILLISECONDS).send(new BufferingResponseListener() {
251                     @NonNullByDefault({})
252                     @Override
253                     public void onComplete(Result result) {
254                         if (result.isSucceeded()) {
255                             final HttpResponse response = (HttpResponse) result.getResponse();
256                             if (response.getStatus() == 200) {
257                                 try {
258                                     Map<String, String> content = gson.fromJson(getContentAsString(), loginType);
259                                     String accessToken = (content != null) ? content.get("access_token") : null;
260                                     f.complete(accessToken);
261                                 } catch (JsonParseException parseException) {
262                                     logger.warn("Access token request response parsing failed: {}",
263                                             parseException.getMessage());
264                                     f.completeExceptionally(
265                                             new ElroConnectsAccountException("@text/offline.request-failed"));
266                                 }
267                             } else if (response.getStatus() == 401) {
268                                 f.completeExceptionally(
269                                         new ElroConnectsAccountException("@text/offline.credentials-error"));
270                             } else {
271                                 logger.warn("Unexpected response on access token request: {} - {}",
272                                         response.getStatus(), getContentAsString());
273                                 f.completeExceptionally(
274                                         new ElroConnectsAccountException("@text/offline.request-failed"));
275                             }
276                         } else {
277                             Throwable e = result.getFailure();
278                             if (e instanceof SocketTimeoutException || e instanceof TimeoutException) {
279                                 f.completeExceptionally(
280                                         new ElroConnectsAccountException("@text/offline.request-timeout", e));
281                             } else {
282                                 logger.warn("Access token request failed", e);
283                                 f.completeExceptionally(
284                                         new ElroConnectsAccountException("@text/offline.request-failed", e));
285                             }
286                         }
287                     }
288                 });
289
290         return f;
291     }
292
293     private CompletableFuture<@Nullable String> getControllers() {
294         CompletableFuture<@Nullable String> f = new CompletableFuture<>();
295         Request request = client.newRequest(URI.create(ELRO_CLOUD_URL));
296
297         request.method(HttpMethod.GET).header(HttpHeader.AUTHORIZATION, "Bearer " + accessToken)
298                 .header(HttpHeader.ACCEPT, "application/json").timeout(TIMEOUT_MS, TimeUnit.MILLISECONDS)
299                 .send(new BufferingResponseListener() {
300                     @NonNullByDefault({})
301                     @Override
302                     public void onComplete(Result result) {
303                         if (result.isSucceeded()) {
304                             final HttpResponse response = (HttpResponse) result.getResponse();
305                             if (response.getStatus() == 200) {
306                                 f.complete(getContentAsString());
307                             } else if (response.getStatus() == 401) {
308                                 // Access token expired, so clear it and do a poll that will now start with a login
309                                 accessToken = null;
310                                 if (!retry) { // Only retry once to avoid infinite recursive loop if no valid token is
311                                               // received
312                                     retry = true;
313                                     logger.debug("Access token expired, retry");
314                                     poll();
315                                     if (accessToken == null) {
316                                         logger.debug("Request for new token failed");
317                                     }
318                                 } else {
319                                     logger.warn("Unexpected response after getting new token : {} - {}",
320                                             response.getStatus(), getContentAsString());
321                                     f.completeExceptionally(
322                                             new ElroConnectsAccountException("@text/offline.request-failed"));
323                                 }
324                             } else {
325                                 logger.warn("Unexpected response on get controllers request: {} - {}",
326                                         response.getStatus(), getContentAsString());
327                                 f.completeExceptionally(
328                                         new ElroConnectsAccountException("@text/offline.request-failed"));
329                             }
330                         } else {
331                             Throwable e = result.getFailure();
332                             if (e instanceof SocketTimeoutException || e instanceof TimeoutException) {
333                                 f.completeExceptionally(
334                                         new ElroConnectsAccountException("@text/offline.request-timeout", e));
335                             } else {
336                                 logger.warn("Get controllers request failed", e);
337                                 f.completeExceptionally(
338                                         new ElroConnectsAccountException("@text/offline.request-failed", e));
339                             }
340                         }
341                         retry = false;
342                     }
343                 });
344
345         return f;
346     }
347
348     @Override
349     public Collection<Class<? extends ThingHandlerService>> getServices() {
350         return Collections.singleton(ElroConnectsBridgeDiscoveryService.class);
351     }
352
353     /**
354      * @return connectors on the account from the ELRO Connects cloud API
355      */
356     public @Nullable Map<String, ElroConnectsConnector> getDevices() {
357         if (!enableBackgroundDiscovery) {
358             poll();
359         }
360         return devices;
361     }
362
363     @Override
364     public void handleCommand(ChannelUID channelUID, Command command) {
365         // nothing to do, there are no channels
366     }
367
368     public void setDiscoveryService(ElroConnectsBridgeDiscoveryService discoveryService) {
369         this.discoveryService = discoveryService;
370     }
371 }