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