]> git.basschouten.com Git - openhab-addons.git/blob
cebc4cec948d1fabd131e52b8a250a7fb5709aee
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 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.somfytahoma.internal.handler;
14
15 import static org.openhab.binding.somfytahoma.internal.SomfyTahomaBindingConstants.*;
16
17 import java.io.UnsupportedEncodingException;
18 import java.net.URLEncoder;
19 import java.nio.charset.StandardCharsets;
20 import java.time.Duration;
21 import java.util.Collection;
22 import java.util.Collections;
23 import java.util.HashMap;
24 import java.util.List;
25 import java.util.Map;
26 import java.util.concurrent.ConcurrentLinkedQueue;
27 import java.util.concurrent.ExecutionException;
28 import java.util.concurrent.ScheduledFuture;
29 import java.util.concurrent.TimeUnit;
30 import java.util.concurrent.TimeoutException;
31
32 import org.apache.commons.lang.StringUtils;
33 import org.eclipse.jdt.annotation.NonNullByDefault;
34 import org.eclipse.jdt.annotation.Nullable;
35 import org.eclipse.jetty.client.HttpClient;
36 import org.eclipse.jetty.client.api.ContentResponse;
37 import org.eclipse.jetty.client.api.Request;
38 import org.eclipse.jetty.client.util.StringContentProvider;
39 import org.eclipse.jetty.http.HttpHeader;
40 import org.eclipse.jetty.http.HttpMethod;
41 import org.openhab.binding.somfytahoma.internal.config.SomfyTahomaConfig;
42 import org.openhab.binding.somfytahoma.internal.discovery.SomfyTahomaItemDiscoveryService;
43 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaAction;
44 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaActionGroup;
45 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaApplyResponse;
46 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaDevice;
47 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaEvent;
48 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaLoginResponse;
49 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaRegisterEventsResponse;
50 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaSetup;
51 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaState;
52 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaStatus;
53 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaStatusResponse;
54 import org.openhab.core.cache.ExpiringCache;
55 import org.openhab.core.io.net.http.HttpClientFactory;
56 import org.openhab.core.thing.Bridge;
57 import org.openhab.core.thing.ChannelUID;
58 import org.openhab.core.thing.Thing;
59 import org.openhab.core.thing.ThingStatus;
60 import org.openhab.core.thing.ThingStatusDetail;
61 import org.openhab.core.thing.ThingStatusInfo;
62 import org.openhab.core.thing.binding.BaseBridgeHandler;
63 import org.openhab.core.thing.binding.ThingHandlerService;
64 import org.openhab.core.types.Command;
65 import org.slf4j.Logger;
66 import org.slf4j.LoggerFactory;
67
68 import com.google.gson.Gson;
69 import com.google.gson.JsonElement;
70 import com.google.gson.JsonSyntaxException;
71
72 /**
73  * The {@link SomfyTahomaBridgeHandler} is responsible for handling commands, which are
74  * sent to one of the channels.
75  *
76  * @author Ondrej Pecta - Initial contribution
77  */
78 @NonNullByDefault
79 public class SomfyTahomaBridgeHandler extends BaseBridgeHandler {
80
81     private final Logger logger = LoggerFactory.getLogger(SomfyTahomaBridgeHandler.class);
82
83     /**
84      * The shared HttpClient
85      */
86     private final HttpClient httpClient;
87
88     /**
89      * Future to poll for updates
90      */
91     private @Nullable ScheduledFuture<?> pollFuture;
92
93     /**
94      * Future to poll for status
95      */
96     private @Nullable ScheduledFuture<?> statusFuture;
97
98     /**
99      * Future to set reconciliation flag
100      */
101     private @Nullable ScheduledFuture<?> reconciliationFuture;
102
103     // List of futures used for command retries
104     private Collection<ScheduledFuture<?>> retryFutures = new ConcurrentLinkedQueue<ScheduledFuture<?>>();
105
106     /**
107      * List of executions
108      */
109     private Map<String, String> executions = new HashMap<>();
110
111     // Too many requests flag
112     private boolean tooManyRequests = false;
113
114     // Silent relogin flag
115     private boolean reLoginNeeded = false;
116
117     // Reconciliation flag
118     private boolean reconciliation = false;
119
120     /**
121      * Our configuration
122      */
123     protected SomfyTahomaConfig thingConfig = new SomfyTahomaConfig();
124
125     /**
126      * Id of registered events
127      */
128     private String eventsId = "";
129
130     private ExpiringCache<List<SomfyTahomaDevice>> cachedDevices = new ExpiringCache<>(Duration.ofSeconds(30),
131             this::getDevices);
132
133     // Gson & parser
134     private final Gson gson = new Gson();
135
136     public SomfyTahomaBridgeHandler(Bridge thing, HttpClientFactory httpClientFactory) {
137         super(thing);
138         this.httpClient = httpClientFactory.createHttpClient("somfy_" + thing.getUID().getId());
139     }
140
141     @Override
142     public void handleCommand(ChannelUID channelUID, Command command) {
143     }
144
145     @Override
146     public void initialize() {
147         thingConfig = getConfigAs(SomfyTahomaConfig.class);
148
149         try {
150             httpClient.start();
151         } catch (Exception e) {
152             logger.debug("Cannot start http client for: {}", thing.getBridgeUID().getId(), e);
153             return;
154         }
155
156         scheduler.execute(() -> {
157             login();
158             initPolling();
159             logger.debug("Initialize done...");
160         });
161     }
162
163     /**
164      * starts this things polling future
165      */
166     private void initPolling() {
167         stopPolling();
168         pollFuture = scheduler.scheduleWithFixedDelay(() -> {
169             getTahomaUpdates();
170         }, 10, thingConfig.getRefresh(), TimeUnit.SECONDS);
171
172         statusFuture = scheduler.scheduleWithFixedDelay(() -> {
173             refreshTahomaStates();
174         }, 60, thingConfig.getStatusTimeout(), TimeUnit.SECONDS);
175
176         reconciliationFuture = scheduler.scheduleWithFixedDelay(() -> {
177             enableReconciliation();
178         }, RECONCILIATION_TIME, RECONCILIATION_TIME, TimeUnit.SECONDS);
179     }
180
181     public synchronized void login() {
182         String url;
183
184         if (StringUtils.isEmpty(thingConfig.getEmail()) || StringUtils.isEmpty(thingConfig.getPassword())) {
185             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
186                     "Can not access device as username and/or password are null");
187             return;
188         }
189
190         if (tooManyRequests) {
191             logger.debug("Skipping login due to too many requests");
192             return;
193         }
194
195         if (ThingStatus.ONLINE == thing.getStatus() && !reLoginNeeded) {
196             logger.debug("No need to log in, because already logged in");
197             return;
198         }
199
200         reLoginNeeded = false;
201
202         try {
203             url = TAHOMA_API_URL + "login";
204             String urlParameters = "userId=" + urlEncode(thingConfig.getEmail()) + "&userPassword="
205                     + urlEncode(thingConfig.getPassword());
206
207             ContentResponse response = sendRequestBuilder(url, HttpMethod.POST)
208                     .content(new StringContentProvider(urlParameters),
209                             "application/x-www-form-urlencoded; charset=UTF-8")
210                     .send();
211
212             if (logger.isTraceEnabled()) {
213                 logger.trace("Login response: {}", response.getContentAsString());
214             }
215
216             SomfyTahomaLoginResponse data = gson.fromJson(response.getContentAsString(),
217                     SomfyTahomaLoginResponse.class);
218             if (data.isSuccess()) {
219                 logger.debug("SomfyTahoma version: {}", data.getVersion());
220                 String id = registerEvents();
221                 if (id != null && !id.equals(UNAUTHORIZED)) {
222                     eventsId = id;
223                     logger.debug("Events id: {}", eventsId);
224                     updateStatus(ThingStatus.ONLINE);
225                 } else {
226                     logger.debug("Events id error: {}", id);
227                 }
228             } else {
229                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
230                         "Error logging in: " + data.getError());
231                 if (data.getError().startsWith(TOO_MANY_REQUESTS)) {
232                     setTooManyRequests();
233                 }
234             }
235         } catch (JsonSyntaxException e) {
236             logger.debug("Received invalid data", e);
237             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Received invalid data");
238         } catch (InterruptedException | ExecutionException | TimeoutException e) {
239             if (e instanceof ExecutionException) {
240                 if (e.getMessage().contains(AUTHENTICATION_CHALLENGE)) {
241                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
242                             "Authentication challenge");
243                     setTooManyRequests();
244                     return;
245                 }
246             }
247             logger.debug("Cannot get login cookie!", e);
248             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Cannot get login cookie");
249             if (e instanceof InterruptedException) {
250                 Thread.currentThread().interrupt();
251             }
252         }
253     }
254
255     private void setTooManyRequests() {
256         logger.debug("Too many requests error, suspending activity for {} seconds", SUSPEND_TIME);
257         tooManyRequests = true;
258         scheduler.schedule(this::enableLogin, SUSPEND_TIME, TimeUnit.SECONDS);
259     }
260
261     private @Nullable String registerEvents() {
262         SomfyTahomaRegisterEventsResponse response = invokeCallToURL(TAHOMA_EVENTS_URL + "register", "",
263                 HttpMethod.POST, SomfyTahomaRegisterEventsResponse.class);
264         return response != null ? response.getId() : null;
265     }
266
267     private String urlEncode(String text) {
268         try {
269             return URLEncoder.encode(text, StandardCharsets.UTF_8.toString());
270         } catch (UnsupportedEncodingException e) {
271             return text;
272         }
273     }
274
275     private void enableLogin() {
276         tooManyRequests = false;
277     }
278
279     private List<SomfyTahomaEvent> getEvents() {
280         SomfyTahomaEvent[] response = invokeCallToURL(TAHOMA_API_URL + "events/" + eventsId + "/fetch", "",
281                 HttpMethod.POST, SomfyTahomaEvent[].class);
282         return response != null ? List.of(response) : List.of();
283     }
284
285     @Override
286     public void handleRemoval() {
287         super.handleRemoval();
288         logout();
289     }
290
291     @Override
292     public Collection<Class<? extends ThingHandlerService>> getServices() {
293         return Collections.singleton(SomfyTahomaItemDiscoveryService.class);
294     }
295
296     @Override
297     public void dispose() {
298         cleanup();
299         super.dispose();
300     }
301
302     private void cleanup() {
303         logger.debug("Doing cleanup");
304         stopPolling();
305         executions.clear();
306         // cancel all scheduled retries
307         retryFutures.forEach(x -> x.cancel(false));
308
309         try {
310             httpClient.stop();
311         } catch (Exception e) {
312             logger.debug("Error during http client stopping", e);
313         }
314     }
315
316     @Override
317     public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
318         super.bridgeStatusChanged(bridgeStatusInfo);
319         if (ThingStatus.UNINITIALIZED == bridgeStatusInfo.getStatus()) {
320             cleanup();
321         }
322     }
323
324     /**
325      * Stops this thing's polling future
326      */
327     private void stopPolling() {
328         ScheduledFuture<?> localPollFuture = pollFuture;
329         if (localPollFuture != null && !localPollFuture.isCancelled()) {
330             localPollFuture.cancel(true);
331         }
332         ScheduledFuture<?> localStatusFuture = statusFuture;
333         if (localStatusFuture != null && !localStatusFuture.isCancelled()) {
334             localStatusFuture.cancel(true);
335         }
336         ScheduledFuture<?> localReconciliationFuture = reconciliationFuture;
337         if (localReconciliationFuture != null && !localReconciliationFuture.isCancelled()) {
338             localReconciliationFuture.cancel(true);
339         }
340     }
341
342     public List<SomfyTahomaActionGroup> listActionGroups() {
343         SomfyTahomaActionGroup[] list = invokeCallToURL(TAHOMA_API_URL + "actionGroups", "", HttpMethod.GET,
344                 SomfyTahomaActionGroup[].class);
345         return list != null ? List.of(list) : List.of();
346     }
347
348     public @Nullable SomfyTahomaSetup getSetup() {
349         return invokeCallToURL(TAHOMA_API_URL + "setup", "", HttpMethod.GET, SomfyTahomaSetup.class);
350     }
351
352     public List<SomfyTahomaDevice> getDevices() {
353         SomfyTahomaDevice[] response = invokeCallToURL(SETUP_URL + "devices", "", HttpMethod.GET,
354                 SomfyTahomaDevice[].class);
355         return response != null ? List.of(response) : List.of();
356     }
357
358     public synchronized @Nullable SomfyTahomaDevice getCachedDevice(String url) {
359         List<SomfyTahomaDevice> devices = cachedDevices.getValue();
360         for (SomfyTahomaDevice device : devices) {
361             if (url.equals(device.getDeviceURL())) {
362                 return device;
363             }
364         }
365         return null;
366     }
367
368     private void getTahomaUpdates() {
369         logger.debug("Getting Tahoma Updates...");
370         if (ThingStatus.OFFLINE == thing.getStatus() && !reLogin()) {
371             return;
372         }
373
374         List<SomfyTahomaEvent> events = getEvents();
375         logger.trace("Got total of {} events", events.size());
376         for (SomfyTahomaEvent event : events) {
377             processEvent(event);
378         }
379     }
380
381     private void processEvent(SomfyTahomaEvent event) {
382         logger.debug("Got event: {}", event.getName());
383         switch (event.getName()) {
384             case "ExecutionRegisteredEvent":
385                 processExecutionRegisteredEvent(event);
386                 break;
387             case "ExecutionStateChangedEvent":
388                 processExecutionChangedEvent(event);
389                 break;
390             case "DeviceStateChangedEvent":
391                 processStateChangedEvent(event);
392                 break;
393             case "RefreshAllDevicesStatesCompletedEvent":
394                 scheduler.schedule(this::updateThings, 1, TimeUnit.SECONDS);
395                 break;
396             case "GatewayAliveEvent":
397             case "GatewayDownEvent":
398                 processGatewayEvent(event);
399                 break;
400             default:
401                 // ignore other states
402         }
403     }
404
405     private synchronized void updateThings() {
406         boolean needsUpdate = reconciliation;
407
408         for (Thing th : getThing().getThings()) {
409             if (ThingStatus.ONLINE != th.getStatus()) {
410                 needsUpdate = true;
411             }
412         }
413
414         // update all states only if necessary
415         if (needsUpdate) {
416             updateAllStates();
417             reconciliation = false;
418         }
419     }
420
421     private void processExecutionRegisteredEvent(SomfyTahomaEvent event) {
422         JsonElement el = event.getAction();
423         if (el.isJsonArray()) {
424             SomfyTahomaAction[] actions = gson.fromJson(el, SomfyTahomaAction[].class);
425             for (SomfyTahomaAction action : actions) {
426                 registerExecution(action.getDeviceURL(), event.getExecId());
427             }
428         } else {
429             SomfyTahomaAction action = gson.fromJson(el, SomfyTahomaAction.class);
430             registerExecution(action.getDeviceURL(), event.getExecId());
431         }
432     }
433
434     private void processExecutionChangedEvent(SomfyTahomaEvent event) {
435         if (FAILED_EVENT.equals(event.getNewState()) || COMPLETED_EVENT.equals(event.getNewState())) {
436             logger.debug("Removing execution id: {}", event.getExecId());
437             unregisterExecution(event.getExecId());
438         }
439     }
440
441     private void registerExecution(String url, String execId) {
442         if (executions.containsKey(url)) {
443             executions.remove(url);
444             logger.debug("Previous execution exists for url: {}", url);
445         }
446         executions.put(url, execId);
447     }
448
449     private void unregisterExecution(String execId) {
450         if (executions.containsValue(execId)) {
451             executions.values().removeAll(Collections.singleton(execId));
452         } else {
453             logger.debug("Cannot remove execution id: {}, because it is not registered", execId);
454         }
455     }
456
457     private void processGatewayEvent(SomfyTahomaEvent event) {
458         // update gateway status
459         for (Thing th : getThing().getThings()) {
460             if (THING_TYPE_GATEWAY.equals(th.getThingTypeUID())) {
461                 SomfyTahomaGatewayHandler gatewayHandler = (SomfyTahomaGatewayHandler) th.getHandler();
462                 if (gatewayHandler != null && gatewayHandler.getGateWayId().equals(event.getGatewayId())) {
463                     gatewayHandler.refresh(STATUS);
464                 }
465             }
466         }
467     }
468
469     private synchronized void updateAllStates() {
470         logger.debug("Updating all states");
471         getDevices().forEach(device -> updateDevice(device));
472     }
473
474     private void updateDevice(SomfyTahomaDevice device) {
475         String url = device.getDeviceURL();
476         List<SomfyTahomaState> states = device.getStates();
477         updateDevice(url, states);
478     }
479
480     private void updateDevice(String url, List<SomfyTahomaState> states) {
481         Thing th = getThingByDeviceUrl(url);
482         if (th == null) {
483             return;
484         }
485         SomfyTahomaBaseThingHandler handler = (SomfyTahomaBaseThingHandler) th.getHandler();
486         if (handler != null) {
487             handler.updateThingStatus(states);
488             handler.updateThingChannels(states);
489         }
490     }
491
492     private void processStateChangedEvent(SomfyTahomaEvent event) {
493         String deviceUrl = event.getDeviceUrl();
494         List<SomfyTahomaState> states = event.getDeviceStates();
495         logger.debug("States for device {} : {}", deviceUrl, states);
496         Thing thing = getThingByDeviceUrl(deviceUrl);
497
498         if (thing != null) {
499             logger.debug("Updating status of thing: {}", thing.getLabel());
500             SomfyTahomaBaseThingHandler handler = (SomfyTahomaBaseThingHandler) thing.getHandler();
501
502             if (handler != null) {
503                 // update thing status
504                 handler.updateThingStatus(states);
505                 handler.updateThingChannels(states);
506             }
507         } else {
508             logger.debug("Thing handler is null, probably not bound thing.");
509         }
510     }
511
512     private void enableReconciliation() {
513         logger.debug("Enabling reconciliation");
514         reconciliation = true;
515     }
516
517     private void refreshTahomaStates() {
518         logger.debug("Refreshing Tahoma states...");
519         if (ThingStatus.OFFLINE == thing.getStatus() && !reLogin()) {
520             return;
521         }
522
523         // force Tahoma to ask for actual states
524         forceGatewaySync();
525     }
526
527     private @Nullable Thing getThingByDeviceUrl(String deviceUrl) {
528         for (Thing th : getThing().getThings()) {
529             String url = (String) th.getConfiguration().get("url");
530             if (deviceUrl.equals(url)) {
531                 return th;
532             }
533         }
534         return null;
535     }
536
537     private void logout() {
538         try {
539             eventsId = "";
540             sendGetToTahomaWithCookie(TAHOMA_API_URL + "logout");
541         } catch (InterruptedException | ExecutionException | TimeoutException e) {
542             logger.debug("Cannot send logout command!", e);
543             if (e instanceof InterruptedException) {
544                 Thread.currentThread().interrupt();
545             }
546         }
547     }
548
549     private String sendPostToTahomaWithCookie(String url, String urlParameters)
550             throws InterruptedException, ExecutionException, TimeoutException {
551         return sendMethodToTahomaWithCookie(url, HttpMethod.POST, urlParameters);
552     }
553
554     private String sendGetToTahomaWithCookie(String url)
555             throws InterruptedException, ExecutionException, TimeoutException {
556         return sendMethodToTahomaWithCookie(url, HttpMethod.GET);
557     }
558
559     private String sendPutToTahomaWithCookie(String url)
560             throws InterruptedException, ExecutionException, TimeoutException {
561         return sendMethodToTahomaWithCookie(url, HttpMethod.PUT);
562     }
563
564     private String sendDeleteToTahomaWithCookie(String url)
565             throws InterruptedException, ExecutionException, TimeoutException {
566         return sendMethodToTahomaWithCookie(url, HttpMethod.DELETE);
567     }
568
569     private String sendMethodToTahomaWithCookie(String url, HttpMethod method)
570             throws InterruptedException, ExecutionException, TimeoutException {
571         return sendMethodToTahomaWithCookie(url, method, "");
572     }
573
574     private String sendMethodToTahomaWithCookie(String url, HttpMethod method, String urlParameters)
575             throws InterruptedException, ExecutionException, TimeoutException {
576         logger.trace("Sending {} to url: {} with data: {}", method.asString(), url, urlParameters);
577         Request request = sendRequestBuilder(url, method);
578         if (StringUtils.isNotEmpty(urlParameters)) {
579             request = request.content(new StringContentProvider(urlParameters), "application/json;charset=UTF-8");
580         }
581
582         ContentResponse response = request.send();
583
584         if (logger.isTraceEnabled()) {
585             logger.trace("Response: {}", response.getContentAsString());
586         }
587
588         if (response.getStatus() < 200 || response.getStatus() >= 300) {
589             logger.debug("Received unexpected status code: {}", response.getStatus());
590         }
591         return response.getContentAsString();
592     }
593
594     private Request sendRequestBuilder(String url, HttpMethod method) {
595         return httpClient.newRequest(url).method(method).header(HttpHeader.ACCEPT_LANGUAGE, "en-US,en")
596                 .header(HttpHeader.ACCEPT_ENCODING, "gzip, deflate").header("X-Requested-With", "XMLHttpRequest")
597                 .timeout(TAHOMA_TIMEOUT, TimeUnit.SECONDS).agent(TAHOMA_AGENT);
598     }
599
600     public void sendCommand(String io, String command, String params, String url) {
601         if (ThingStatus.OFFLINE == thing.getStatus() && !reLogin()) {
602             return;
603         }
604
605         removeFinishedRetries();
606
607         boolean result = sendCommandInternal(io, command, params, url);
608         if (!result) {
609             scheduleRetry(io, command, params, url, thingConfig.getRetries());
610         }
611     }
612
613     private void repeatSendCommandInternal(String io, String command, String params, String url, int retries) {
614         logger.debug("Retrying command, retries left: {}", retries);
615         boolean result = sendCommandInternal(io, command, params, url);
616         if (!result && (retries > 0)) {
617             scheduleRetry(io, command, params, url, retries - 1);
618         }
619     }
620
621     private boolean sendCommandInternal(String io, String command, String params, String url) {
622         String value = params.equals("[]") ? command : params.replace("\"", "");
623         String urlParameters = "{\"label\":\"" + getThingLabelByURL(io) + " - " + value
624                 + " - OH2\",\"actions\":[{\"deviceURL\":\"" + io + "\",\"commands\":[{\"name\":\"" + command
625                 + "\",\"parameters\":" + params + "}]}]}";
626         SomfyTahomaApplyResponse response = invokeCallToURL(url, urlParameters, HttpMethod.POST,
627                 SomfyTahomaApplyResponse.class);
628         if (response != null) {
629             if (!response.getExecId().isEmpty()) {
630                 logger.debug("Exec id: {}", response.getExecId());
631                 registerExecution(io, response.getExecId());
632             } else {
633                 logger.debug("ExecId is empty!");
634                 return false;
635             }
636             return true;
637         }
638         return false;
639     }
640
641     private void removeFinishedRetries() {
642         retryFutures.removeIf(x -> x.isDone());
643         logger.debug("Currently {} retries are scheduled.", retryFutures.size());
644     }
645
646     private void scheduleRetry(String io, String command, String params, String url, int retries) {
647         retryFutures.add(scheduler.schedule(() -> {
648             repeatSendCommandInternal(io, command, params, url, retries);
649         }, thingConfig.getRetryDelay(), TimeUnit.MILLISECONDS));
650     }
651
652     private String getThingLabelByURL(String io) {
653         Thing th = getThingByDeviceUrl(io);
654         if (th != null) {
655             if (th.getProperties().containsKey(NAME_STATE)) {
656                 // Return label from Tahoma
657                 return th.getProperties().get(NAME_STATE).replace("\"", "");
658             }
659             // Return label from OH2
660             String label = th.getLabel();
661             return label != null ? label.replace("\"", "") : "";
662         }
663         return "null";
664     }
665
666     public @Nullable String getCurrentExecutions(String io) {
667         if (executions.containsKey(io)) {
668             return executions.get(io);
669         }
670         return null;
671     }
672
673     public void cancelExecution(String executionId) {
674         invokeCallToURL(DELETE_URL + executionId, "", HttpMethod.DELETE, null);
675     }
676
677     public void executeActionGroup(String id) {
678         if (ThingStatus.OFFLINE == thing.getStatus() && !reLogin()) {
679             return;
680         }
681         String execId = executeActionGroupInternal(id);
682         if (execId == null) {
683             execId = executeActionGroupInternal(id);
684         }
685         if (execId != null) {
686             registerExecution(id, execId);
687         }
688     }
689
690     private boolean reLogin() {
691         logger.debug("Doing relogin");
692         reLoginNeeded = true;
693         login();
694         return ThingStatus.OFFLINE != thing.getStatus();
695     }
696
697     public @Nullable String executeActionGroupInternal(String id) {
698         SomfyTahomaApplyResponse response = invokeCallToURL(EXEC_URL + id, "", HttpMethod.POST,
699                 SomfyTahomaApplyResponse.class);
700         if (response != null) {
701             if (response.getExecId().isEmpty()) {
702                 logger.debug("Got empty exec response");
703                 return null;
704             }
705             return response.getExecId();
706         }
707         return null;
708     }
709
710     public void forceGatewaySync() {
711         invokeCallToURL(REFRESH_URL, "", HttpMethod.PUT, null);
712     }
713
714     public SomfyTahomaStatus getTahomaStatus(String gatewayId) {
715         SomfyTahomaStatusResponse data = invokeCallToURL(GATEWAYS_URL + gatewayId, "", HttpMethod.GET,
716                 SomfyTahomaStatusResponse.class);
717         if (data != null) {
718             logger.debug("Tahoma status: {}", data.getConnectivity().getStatus());
719             logger.debug("Tahoma protocol version: {}", data.getConnectivity().getProtocolVersion());
720             return data.getConnectivity();
721         }
722         return new SomfyTahomaStatus();
723     }
724
725     private boolean isAuthenticationChallenge(Exception ex) {
726         return ex.getMessage().contains(AUTHENTICATION_CHALLENGE);
727     }
728
729     @Override
730     public void handleConfigurationUpdate(Map<String, Object> configurationParameters) {
731         super.handleConfigurationUpdate(configurationParameters);
732         if (configurationParameters.containsKey("email")) {
733             thingConfig.setEmail(configurationParameters.get("email").toString());
734         }
735         if (configurationParameters.containsKey("password")) {
736             thingConfig.setPassword(configurationParameters.get("password").toString());
737         }
738     }
739
740     public synchronized void refresh(String url, String stateName) {
741         SomfyTahomaState state = invokeCallToURL(DEVICES_URL + urlEncode(url) + "/states/" + stateName, "",
742                 HttpMethod.GET, SomfyTahomaState.class);
743         if (state != null && !state.getName().isEmpty()) {
744             updateDevice(url, List.of(state));
745         }
746     }
747
748     private @Nullable <T> T invokeCallToURL(String url, String urlParameters, HttpMethod method,
749             @Nullable Class<T> classOfT) {
750         String response = "";
751         try {
752             switch (method) {
753                 case GET:
754                     response = sendGetToTahomaWithCookie(url);
755                     break;
756                 case PUT:
757                     response = sendPutToTahomaWithCookie(url);
758                     break;
759                 case POST:
760                     response = sendPostToTahomaWithCookie(url, urlParameters);
761                     break;
762                 case DELETE:
763                     response = sendDeleteToTahomaWithCookie(url);
764                 default:
765             }
766             return classOfT != null ? gson.fromJson(response, classOfT) : null;
767         } catch (JsonSyntaxException e) {
768             logger.debug("Received data: {} is not JSON", response, e);
769             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Received invalid data");
770         } catch (ExecutionException e) {
771             if (isAuthenticationChallenge(e)) {
772                 reLogin();
773             } else {
774                 logger.debug("Cannot call url: {} with params: {}!", url, urlParameters, e);
775                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
776             }
777         } catch (InterruptedException | TimeoutException e) {
778             logger.debug("Cannot call url: {} with params: {}!", url, urlParameters, e);
779             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
780             if (e instanceof InterruptedException) {
781                 Thread.currentThread().interrupt();
782             }
783         }
784         return null;
785     }
786 }