]> git.basschouten.com Git - openhab-addons.git/blob
d2a818bcdcb0bb2f0c3ac6d43b0c50bc386722c2
[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.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.HashMap;
20 import java.util.List;
21 import java.util.Map;
22 import java.util.Set;
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         updateStatus(ThingStatus.UNKNOWN);
125
126         // If background discovery is enabled, start polling (will do login first), else only login to take the thing
127         // online if successful
128         if (enableBackgroundDiscovery) {
129             startPolling();
130         } else {
131             scheduler.execute(this::login);
132         }
133     }
134
135     @Override
136     public void dispose() {
137         logger.debug("Handler disposed");
138         stopPolling();
139         super.dispose();
140     }
141
142     private void startPolling() {
143         final ScheduledFuture<?> localRefreshJob = this.pollingJob;
144         if (localRefreshJob == null || localRefreshJob.isCancelled()) {
145             logger.debug("Start polling");
146             pollingJob = scheduler.scheduleWithFixedDelay(this::poll, 0, REFRESH_INTERVAL_S, TimeUnit.SECONDS);
147         }
148
149         ElroConnectsBridgeDiscoveryService service = this.discoveryService;
150         if (service != null) {
151             service.startBackgroundDiscovery();
152         }
153     }
154
155     private void stopPolling() {
156         ElroConnectsBridgeDiscoveryService service = this.discoveryService;
157         if (service != null) {
158             service.stopBackgroundDiscovery();
159         }
160
161         final ScheduledFuture<?> localPollingJob = this.pollingJob;
162         if (localPollingJob != null && !localPollingJob.isCancelled()) {
163             logger.debug("Stop polling");
164             localPollingJob.cancel(true);
165             pollingJob = null;
166         }
167     }
168
169     private void poll() {
170         logger.debug("Polling");
171
172         // when access token not yet received or expired, try to login first
173         if (accessToken == null) {
174             login();
175         }
176         if (accessToken == null) {
177             return;
178         }
179
180         try {
181             getControllers().handle((devicesList, accountException) -> {
182                 if (devicesList != null) {
183                     logger.trace("deviceList response: {}", devicesList);
184
185                     List<ElroConnectsConnector> response = null;
186                     try {
187                         response = gson.fromJson(devicesList, deviceListType);
188                     } catch (JsonParseException parseException) {
189                         logger.warn("Parsing failed: {}", parseException.getMessage());
190                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
191                                 "@text/offline.request-failed");
192                         return null;
193                     }
194                     Map<String, ElroConnectsConnector> devices = new HashMap<>();
195                     if (response != null) {
196                         response.forEach(d -> devices.put(d.getDevTid(), d));
197                     }
198                     this.devices = devices;
199                     updateStatus(ThingStatus.ONLINE);
200                 } else {
201                     if (accountException == null) {
202                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
203                     } else {
204                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
205                                 accountException.getLocalizedMessage());
206                     }
207                 }
208
209                 return null;
210             }).get();
211         } catch (InterruptedException e) {
212             updateStatus(ThingStatus.OFFLINE);
213             Thread.currentThread().interrupt();
214         } catch (ExecutionException e) {
215             logger.debug("Poll exception", e);
216             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
217         }
218     }
219
220     private void login() {
221         logger.debug("Login");
222         try {
223             getAccessToken().handle((accessToken, accountException) -> {
224                 this.accessToken = accessToken;
225                 if (accessToken != null) {
226                     updateStatus(ThingStatus.ONLINE);
227                 } else {
228                     if (accountException == null) {
229                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
230                     } else {
231                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
232                                 accountException.getLocalizedMessage());
233                     }
234                 }
235
236                 return null;
237             }).get();
238         } catch (InterruptedException e) {
239             updateStatus(ThingStatus.OFFLINE);
240             Thread.currentThread().interrupt();
241         } catch (ExecutionException e) {
242             logger.debug("Login exception", e);
243             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
244         }
245     }
246
247     private CompletableFuture<@Nullable String> getAccessToken() {
248         CompletableFuture<@Nullable String> f = new CompletableFuture<>();
249         Request request = client.newRequest(URI.create(ELRO_CLOUD_LOGIN_URL));
250
251         request.method(HttpMethod.POST).content(new StringContentProvider(loginJson), "application/json")
252                 .timeout(TIMEOUT_MS, TimeUnit.MILLISECONDS).send(new BufferingResponseListener() {
253                     @NonNullByDefault({})
254                     @Override
255                     public void onComplete(Result result) {
256                         if (result.isSucceeded()) {
257                             final HttpResponse response = (HttpResponse) result.getResponse();
258                             if (response.getStatus() == 200) {
259                                 try {
260                                     Map<String, String> content = gson.fromJson(getContentAsString(), loginType);
261                                     String accessToken = (content != null) ? content.get("access_token") : null;
262                                     f.complete(accessToken);
263                                 } catch (JsonParseException parseException) {
264                                     logger.warn("Access token request response parsing failed: {}",
265                                             parseException.getMessage());
266                                     f.completeExceptionally(
267                                             new ElroConnectsAccountException("@text/offline.request-failed"));
268                                 }
269                             } else if (response.getStatus() == 401) {
270                                 f.completeExceptionally(
271                                         new ElroConnectsAccountException("@text/offline.credentials-error"));
272                             } else {
273                                 logger.warn("Unexpected response on access token request: {} - {}",
274                                         response.getStatus(), getContentAsString());
275                                 f.completeExceptionally(
276                                         new ElroConnectsAccountException("@text/offline.request-failed"));
277                             }
278                         } else {
279                             Throwable e = result.getFailure();
280                             if (e instanceof SocketTimeoutException || e instanceof TimeoutException) {
281                                 f.completeExceptionally(
282                                         new ElroConnectsAccountException("@text/offline.request-timeout", e));
283                             } else {
284                                 logger.warn("Access token request failed", e);
285                                 f.completeExceptionally(
286                                         new ElroConnectsAccountException("@text/offline.request-failed", e));
287                             }
288                         }
289                     }
290                 });
291
292         return f;
293     }
294
295     private CompletableFuture<@Nullable String> getControllers() {
296         CompletableFuture<@Nullable String> f = new CompletableFuture<>();
297         Request request = client.newRequest(URI.create(ELRO_CLOUD_URL));
298
299         request.method(HttpMethod.GET).header(HttpHeader.AUTHORIZATION, "Bearer " + accessToken)
300                 .header(HttpHeader.ACCEPT, "application/json").timeout(TIMEOUT_MS, TimeUnit.MILLISECONDS)
301                 .send(new BufferingResponseListener() {
302                     @NonNullByDefault({})
303                     @Override
304                     public void onComplete(Result result) {
305                         if (result.isSucceeded()) {
306                             final HttpResponse response = (HttpResponse) result.getResponse();
307                             if (response.getStatus() == 200) {
308                                 f.complete(getContentAsString());
309                             } else if (response.getStatus() == 401) {
310                                 // Access token expired, so clear it and do a poll that will now start with a login
311                                 accessToken = null;
312                                 if (!retry) { // Only retry once to avoid infinite recursive loop if no valid token is
313                                               // received
314                                     retry = true;
315                                     logger.debug("Access token expired, retry");
316                                     poll();
317                                     if (accessToken == null) {
318                                         logger.debug("Request for new token failed");
319                                     }
320                                 } else {
321                                     logger.warn("Unexpected response after getting new token : {} - {}",
322                                             response.getStatus(), getContentAsString());
323                                     f.completeExceptionally(
324                                             new ElroConnectsAccountException("@text/offline.request-failed"));
325                                 }
326                             } else {
327                                 logger.warn("Unexpected response on get controllers request: {} - {}",
328                                         response.getStatus(), getContentAsString());
329                                 f.completeExceptionally(
330                                         new ElroConnectsAccountException("@text/offline.request-failed"));
331                             }
332                         } else {
333                             Throwable e = result.getFailure();
334                             if (e instanceof SocketTimeoutException || e instanceof TimeoutException) {
335                                 f.completeExceptionally(
336                                         new ElroConnectsAccountException("@text/offline.request-timeout", e));
337                             } else {
338                                 logger.warn("Get controllers request failed", e);
339                                 f.completeExceptionally(
340                                         new ElroConnectsAccountException("@text/offline.request-failed", e));
341                             }
342                         }
343                         retry = false;
344                     }
345                 });
346
347         return f;
348     }
349
350     @Override
351     public Collection<Class<? extends ThingHandlerService>> getServices() {
352         return Set.of(ElroConnectsBridgeDiscoveryService.class);
353     }
354
355     /**
356      * @return connectors on the account from the ELRO Connects cloud API
357      */
358     public @Nullable Map<String, ElroConnectsConnector> getDevices() {
359         if (!enableBackgroundDiscovery) {
360             poll();
361         }
362         return devices;
363     }
364
365     @Override
366     public void handleCommand(ChannelUID channelUID, Command command) {
367         // nothing to do, there are no channels
368     }
369
370     public void setDiscoveryService(ElroConnectsBridgeDiscoveryService discoveryService) {
371         this.discoveryService = discoveryService;
372     }
373 }