2 * Copyright (c) 2010-2022 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.elroconnects.internal.handler;
15 import java.lang.reflect.Type;
16 import java.net.SocketTimeoutException;
18 import java.util.Collection;
19 import java.util.Collections;
20 import java.util.HashMap;
21 import java.util.List;
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;
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;
51 import com.google.gson.Gson;
52 import com.google.gson.JsonParseException;
53 import com.google.gson.reflect.TypeToken;
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.
59 * @author Mark Herwege - Initial contribution
62 public class ElroConnectsAccountHandler extends BaseBridgeHandler {
64 private final Logger logger = LoggerFactory.getLogger(ElroConnectsAccountHandler.class);
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
70 private static final int TIMEOUT_MS = 2500;
71 private static final int REFRESH_INTERVAL_S = 300;
73 private boolean enableBackgroundDiscovery = true;
75 private volatile @Nullable ScheduledFuture<?> pollingJob;
76 private final HttpClient client;
78 private Gson gson = new Gson();
79 private Type loginType = new TypeToken<Map<String, String>>() {
81 private Type deviceListType = new TypeToken<List<ElroConnectsConnector>>() {
84 private final Map<String, String> login = new HashMap<>();
85 private String loginJson = "";
86 private volatile @Nullable String accessToken;
88 private volatile boolean retry = false; // Flag for retrying login when token expired during poll, prevents multiple
91 private volatile Map<String, ElroConnectsConnector> devices = new HashMap<>();
93 private @Nullable ElroConnectsBridgeDiscoveryService discoveryService = null;
95 public ElroConnectsAccountHandler(Bridge bridge, HttpClient client) {
98 login.put("pid", ELRO_PID);
99 login.put("clientType", "WEB");
103 public void initialize() {
106 ElroConnectsAccountConfiguration config = getConfigAs(ElroConnectsAccountConfiguration.class);
107 String username = config.username;
108 String password = config.password;
109 enableBackgroundDiscovery = config.enableBackgroundDiscovery;
111 if ((username == null) || username.isEmpty()) {
112 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING, "@text/offline.no-username");
115 if ((password == null) || password.isEmpty()) {
116 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING, "@text/offline.no-password");
120 login.put("username", username);
121 login.put("password", password);
122 loginJson = gson.toJson(login);
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) {
129 scheduler.execute(this::login);
134 public void dispose() {
135 logger.debug("Handler disposed");
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);
147 ElroConnectsBridgeDiscoveryService service = this.discoveryService;
148 if (service != null) {
149 service.startBackgroundDiscovery();
153 private void stopPolling() {
154 ElroConnectsBridgeDiscoveryService service = this.discoveryService;
155 if (service != null) {
156 service.stopBackgroundDiscovery();
159 final ScheduledFuture<?> localPollingJob = this.pollingJob;
160 if (localPollingJob != null && !localPollingJob.isCancelled()) {
161 logger.debug("Stop polling");
162 localPollingJob.cancel(true);
167 private void poll() {
168 logger.debug("Polling");
170 // when access token not yet received or expired, try to login first
171 if (accessToken == null) {
174 if (accessToken == null) {
179 getControllers().handle((devicesList, accountException) -> {
180 if (devicesList != null) {
181 logger.trace("deviceList response: {}", devicesList);
183 List<ElroConnectsConnector> response = null;
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");
192 Map<String, ElroConnectsConnector> devices = new HashMap<>();
193 if (response != null) {
194 response.forEach(d -> devices.put(d.getDevTid(), d));
196 this.devices = devices;
197 updateStatus(ThingStatus.ONLINE);
199 if (accountException == null) {
200 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
202 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
203 accountException.getLocalizedMessage());
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);
218 private void login() {
219 logger.debug("Login");
221 getAccessToken().handle((accessToken, accountException) -> {
222 this.accessToken = accessToken;
223 if (accessToken != null) {
224 updateStatus(ThingStatus.ONLINE);
226 if (accountException == null) {
227 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
229 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
230 accountException.getLocalizedMessage());
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);
245 private CompletableFuture<@Nullable String> getAccessToken() {
246 CompletableFuture<@Nullable String> f = new CompletableFuture<>();
247 Request request = client.newRequest(URI.create(ELRO_CLOUD_LOGIN_URL));
249 request.method(HttpMethod.POST).content(new StringContentProvider(loginJson), "application/json")
250 .timeout(TIMEOUT_MS, TimeUnit.MILLISECONDS).send(new BufferingResponseListener() {
251 @NonNullByDefault({})
253 public void onComplete(Result result) {
254 if (result.isSucceeded()) {
255 final HttpResponse response = (HttpResponse) result.getResponse();
256 if (response.getStatus() == 200) {
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"));
267 } else if (response.getStatus() == 401) {
268 f.completeExceptionally(
269 new ElroConnectsAccountException("@text/offline.credentials-error"));
271 logger.warn("Unexpected response on access token request: {} - {}",
272 response.getStatus(), getContentAsString());
273 f.completeExceptionally(
274 new ElroConnectsAccountException("@text/offline.request-failed"));
277 Throwable e = result.getFailure();
278 if (e instanceof SocketTimeoutException || e instanceof TimeoutException) {
279 f.completeExceptionally(
280 new ElroConnectsAccountException("@text/offline.request-timeout", e));
282 logger.warn("Access token request failed", e);
283 f.completeExceptionally(
284 new ElroConnectsAccountException("@text/offline.request-failed", e));
293 private CompletableFuture<@Nullable String> getControllers() {
294 CompletableFuture<@Nullable String> f = new CompletableFuture<>();
295 Request request = client.newRequest(URI.create(ELRO_CLOUD_URL));
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({})
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
310 if (!retry) { // Only retry once to avoid infinite recursive loop if no valid token is
313 logger.debug("Access token expired, retry");
315 if (accessToken == null) {
316 logger.debug("Request for new token failed");
319 logger.warn("Unexpected response after getting new token : {} - {}",
320 response.getStatus(), getContentAsString());
321 f.completeExceptionally(
322 new ElroConnectsAccountException("@text/offline.request-failed"));
325 logger.warn("Unexpected response on get controllers request: {} - {}",
326 response.getStatus(), getContentAsString());
327 f.completeExceptionally(
328 new ElroConnectsAccountException("@text/offline.request-failed"));
331 Throwable e = result.getFailure();
332 if (e instanceof SocketTimeoutException || e instanceof TimeoutException) {
333 f.completeExceptionally(
334 new ElroConnectsAccountException("@text/offline.request-timeout", e));
336 logger.warn("Get controllers request failed", e);
337 f.completeExceptionally(
338 new ElroConnectsAccountException("@text/offline.request-failed", e));
349 public Collection<Class<? extends ThingHandlerService>> getServices() {
350 return Collections.singleton(ElroConnectsBridgeDiscoveryService.class);
354 * @return connectors on the account from the ELRO Connects cloud API
356 public @Nullable Map<String, ElroConnectsConnector> getDevices() {
357 if (!enableBackgroundDiscovery) {
364 public void handleCommand(ChannelUID channelUID, Command command) {
365 // nothing to do, there are no channels
368 public void setDiscoveryService(ElroConnectsBridgeDiscoveryService discoveryService) {
369 this.discoveryService = discoveryService;