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