2 * Copyright (c) 2010-2023 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 updateStatus(ThingStatus.UNKNOWN);
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) {
131 scheduler.execute(this::login);
136 public void dispose() {
137 logger.debug("Handler disposed");
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);
149 ElroConnectsBridgeDiscoveryService service = this.discoveryService;
150 if (service != null) {
151 service.startBackgroundDiscovery();
155 private void stopPolling() {
156 ElroConnectsBridgeDiscoveryService service = this.discoveryService;
157 if (service != null) {
158 service.stopBackgroundDiscovery();
161 final ScheduledFuture<?> localPollingJob = this.pollingJob;
162 if (localPollingJob != null && !localPollingJob.isCancelled()) {
163 logger.debug("Stop polling");
164 localPollingJob.cancel(true);
169 private void poll() {
170 logger.debug("Polling");
172 // when access token not yet received or expired, try to login first
173 if (accessToken == null) {
176 if (accessToken == null) {
181 getControllers().handle((devicesList, accountException) -> {
182 if (devicesList != null) {
183 logger.trace("deviceList response: {}", devicesList);
185 List<ElroConnectsConnector> response = null;
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");
194 Map<String, ElroConnectsConnector> devices = new HashMap<>();
195 if (response != null) {
196 response.forEach(d -> devices.put(d.getDevTid(), d));
198 this.devices = devices;
199 updateStatus(ThingStatus.ONLINE);
201 if (accountException == null) {
202 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
204 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
205 accountException.getLocalizedMessage());
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);
220 private void login() {
221 logger.debug("Login");
223 getAccessToken().handle((accessToken, accountException) -> {
224 this.accessToken = accessToken;
225 if (accessToken != null) {
226 updateStatus(ThingStatus.ONLINE);
228 if (accountException == null) {
229 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
231 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
232 accountException.getLocalizedMessage());
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);
247 private CompletableFuture<@Nullable String> getAccessToken() {
248 CompletableFuture<@Nullable String> f = new CompletableFuture<>();
249 Request request = client.newRequest(URI.create(ELRO_CLOUD_LOGIN_URL));
251 request.method(HttpMethod.POST).content(new StringContentProvider(loginJson), "application/json")
252 .timeout(TIMEOUT_MS, TimeUnit.MILLISECONDS).send(new BufferingResponseListener() {
253 @NonNullByDefault({})
255 public void onComplete(Result result) {
256 if (result.isSucceeded()) {
257 final HttpResponse response = (HttpResponse) result.getResponse();
258 if (response.getStatus() == 200) {
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"));
269 } else if (response.getStatus() == 401) {
270 f.completeExceptionally(
271 new ElroConnectsAccountException("@text/offline.credentials-error"));
273 logger.warn("Unexpected response on access token request: {} - {}",
274 response.getStatus(), getContentAsString());
275 f.completeExceptionally(
276 new ElroConnectsAccountException("@text/offline.request-failed"));
279 Throwable e = result.getFailure();
280 if (e instanceof SocketTimeoutException || e instanceof TimeoutException) {
281 f.completeExceptionally(
282 new ElroConnectsAccountException("@text/offline.request-timeout", e));
284 logger.warn("Access token request failed", e);
285 f.completeExceptionally(
286 new ElroConnectsAccountException("@text/offline.request-failed", e));
295 private CompletableFuture<@Nullable String> getControllers() {
296 CompletableFuture<@Nullable String> f = new CompletableFuture<>();
297 Request request = client.newRequest(URI.create(ELRO_CLOUD_URL));
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({})
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
312 if (!retry) { // Only retry once to avoid infinite recursive loop if no valid token is
315 logger.debug("Access token expired, retry");
317 if (accessToken == null) {
318 logger.debug("Request for new token failed");
321 logger.warn("Unexpected response after getting new token : {} - {}",
322 response.getStatus(), getContentAsString());
323 f.completeExceptionally(
324 new ElroConnectsAccountException("@text/offline.request-failed"));
327 logger.warn("Unexpected response on get controllers request: {} - {}",
328 response.getStatus(), getContentAsString());
329 f.completeExceptionally(
330 new ElroConnectsAccountException("@text/offline.request-failed"));
333 Throwable e = result.getFailure();
334 if (e instanceof SocketTimeoutException || e instanceof TimeoutException) {
335 f.completeExceptionally(
336 new ElroConnectsAccountException("@text/offline.request-timeout", e));
338 logger.warn("Get controllers request failed", e);
339 f.completeExceptionally(
340 new ElroConnectsAccountException("@text/offline.request-failed", e));
351 public Collection<Class<? extends ThingHandlerService>> getServices() {
352 return Collections.singleton(ElroConnectsBridgeDiscoveryService.class);
356 * @return connectors on the account from the ELRO Connects cloud API
358 public @Nullable Map<String, ElroConnectsConnector> getDevices() {
359 if (!enableBackgroundDiscovery) {
366 public void handleCommand(ChannelUID channelUID, Command command) {
367 // nothing to do, there are no channels
370 public void setDiscoveryService(ElroConnectsBridgeDiscoveryService discoveryService) {
371 this.discoveryService = discoveryService;