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