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