]> git.basschouten.com Git - openhab-addons.git/blob
638b8383d081b25a88270ddba0a2bfa9d6ac83f8
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 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.IOException;
18 import java.net.InetAddress;
19 import java.net.URLEncoder;
20 import java.nio.charset.StandardCharsets;
21 import java.time.Duration;
22 import java.util.Collection;
23 import java.util.Collections;
24 import java.util.HashMap;
25 import java.util.List;
26 import java.util.Map;
27 import java.util.concurrent.ConcurrentLinkedQueue;
28 import java.util.concurrent.ExecutionException;
29 import java.util.concurrent.ScheduledFuture;
30 import java.util.concurrent.TimeUnit;
31 import java.util.concurrent.TimeoutException;
32
33 import javax.jmdns.JmDNS;
34 import javax.ws.rs.core.MediaType;
35
36 import org.eclipse.jdt.annotation.NonNullByDefault;
37 import org.eclipse.jdt.annotation.Nullable;
38 import org.eclipse.jetty.client.HttpClient;
39 import org.eclipse.jetty.client.WWWAuthenticationProtocolHandler;
40 import org.eclipse.jetty.client.api.ContentResponse;
41 import org.eclipse.jetty.client.api.Request;
42 import org.eclipse.jetty.client.util.StringContentProvider;
43 import org.eclipse.jetty.http.HttpHeader;
44 import org.eclipse.jetty.http.HttpMethod;
45 import org.eclipse.jetty.util.ssl.SslContextFactory;
46 import org.openhab.binding.somfytahoma.internal.config.SomfyTahomaConfig;
47 import org.openhab.binding.somfytahoma.internal.discovery.SomfyTahomaItemDiscoveryService;
48 import org.openhab.binding.somfytahoma.internal.discovery.SomfyTahomaMDNSDiscoveryListener;
49 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaAction;
50 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaActionGroup;
51 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaApplyResponse;
52 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaDevice;
53 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaError;
54 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaEvent;
55 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaLocalToken;
56 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaLoginResponse;
57 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaOauth2Error;
58 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaOauth2Reponse;
59 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaRegisterEventsResponse;
60 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaSetup;
61 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaState;
62 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaStatus;
63 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaStatusResponse;
64 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaTokenReponse;
65 import org.openhab.core.cache.ExpiringCache;
66 import org.openhab.core.config.core.Configuration;
67 import org.openhab.core.io.net.http.HttpClientFactory;
68 import org.openhab.core.thing.Bridge;
69 import org.openhab.core.thing.ChannelUID;
70 import org.openhab.core.thing.Thing;
71 import org.openhab.core.thing.ThingStatus;
72 import org.openhab.core.thing.ThingStatusDetail;
73 import org.openhab.core.thing.ThingStatusInfo;
74 import org.openhab.core.thing.binding.BaseBridgeHandler;
75 import org.openhab.core.thing.binding.ThingHandlerService;
76 import org.openhab.core.thing.util.ThingWebClientUtil;
77 import org.openhab.core.types.Command;
78 import org.slf4j.Logger;
79 import org.slf4j.LoggerFactory;
80
81 import com.google.gson.Gson;
82 import com.google.gson.JsonElement;
83 import com.google.gson.JsonSyntaxException;
84
85 /**
86  * The {@link SomfyTahomaBridgeHandler} is responsible for handling commands, which are
87  * sent to one of the channels.
88  *
89  * @author Ondrej Pecta - Initial contribution
90  * @author Laurent Garnier - Other portals integration
91  */
92 @NonNullByDefault
93 public class SomfyTahomaBridgeHandler extends BaseBridgeHandler {
94
95     private final Logger logger = LoggerFactory.getLogger(SomfyTahomaBridgeHandler.class);
96
97     /**
98      * The shared HttpClient
99      */
100     private @Nullable HttpClient httpClient;
101
102     /**
103      * Future to poll for updates
104      */
105     private @Nullable ScheduledFuture<?> pollFuture;
106
107     /**
108      * Future to poll for status
109      */
110     private @Nullable ScheduledFuture<?> statusFuture;
111
112     /**
113      * Future to set reconciliation flag
114      */
115     private @Nullable ScheduledFuture<?> reconciliationFuture;
116
117     /**
118      * Future for postponed login
119      */
120     private @Nullable ScheduledFuture<?> loginFuture;
121
122     // List of futures used for command retries
123     private Collection<ScheduledFuture<?>> retryFutures = new ConcurrentLinkedQueue<ScheduledFuture<?>>();
124
125     /**
126      * List of executions
127      */
128     private Map<String, String> executions = new HashMap<>();
129
130     // Too many requests flag
131     private boolean tooManyRequests = false;
132
133     // Silent relogin flag
134     private boolean reLoginNeeded = false;
135
136     // Reconciliation flag
137     private boolean reconciliation = false;
138
139     // Cloud fallback
140     private boolean cloudFallback = false;
141
142     /**
143      * Our configuration
144      */
145     protected SomfyTahomaConfig thingConfig = new SomfyTahomaConfig();
146
147     /**
148      * Id of registered events
149      */
150     private String eventsId = "";
151
152     private String localToken = "";
153
154     private Map<String, SomfyTahomaDevice> devicePlaces = new HashMap<>();
155
156     private ExpiringCache<List<SomfyTahomaDevice>> cachedDevices = new ExpiringCache<>(Duration.ofSeconds(30),
157             this::getDevices);
158
159     // Gson & parser
160     private final Gson gson = new Gson();
161
162     private final HttpClientFactory httpClientFactory;
163
164     public SomfyTahomaBridgeHandler(Bridge thing, HttpClientFactory httpClientFactory) {
165         super(thing);
166         this.httpClientFactory = httpClientFactory;
167     }
168
169     @Override
170     public void handleCommand(ChannelUID channelUID, Command command) {
171     }
172
173     @Override
174     public void initialize() {
175         updateStatus(ThingStatus.UNKNOWN);
176         thingConfig = getConfigAs(SomfyTahomaConfig.class);
177         createHttpClient();
178
179         scheduler.execute(() -> {
180             login();
181             initPolling();
182             logger.debug("Initialize done...");
183         });
184     }
185
186     private void createHttpClient() {
187         // let's create the right http client
188         String clientName = ThingWebClientUtil.buildWebClientConsumerName(thing.getUID(), null);
189         if (thingConfig.isDevMode()) {
190             this.httpClient = httpClientFactory.createHttpClient(clientName, new SslContextFactory.Client(true));
191         } else {
192             this.httpClient = httpClientFactory.createHttpClient(clientName);
193         }
194
195         try {
196             httpClient.start();
197         } catch (Exception e) {
198             logger.debug("Cannot start http client for: {}", thing.getBridgeUID().getId(), e);
199             return;
200         }
201         // Remove the WWWAuth protocol handler since Tahoma is not fully compliant
202         httpClient.getProtocolHandlers().remove(WWWAuthenticationProtocolHandler.NAME);
203     }
204
205     /**
206      * starts this things polling future
207      */
208     private void initPolling() {
209         stopPolling();
210         scheduleGetUpdates(10);
211
212         statusFuture = scheduler.scheduleWithFixedDelay(() -> {
213             refreshTahomaStates();
214         }, 60, thingConfig.getStatusTimeout(), TimeUnit.SECONDS);
215
216         reconciliationFuture = scheduler.scheduleWithFixedDelay(() -> {
217             enableReconciliation();
218         }, RECONCILIATION_TIME, RECONCILIATION_TIME, TimeUnit.SECONDS);
219     }
220
221     private void scheduleGetUpdates(long delay) {
222         pollFuture = scheduler.schedule(() -> {
223             getTahomaUpdates();
224             scheduleNextGetUpdates();
225         }, delay, TimeUnit.SECONDS);
226     }
227
228     private void scheduleNextGetUpdates() {
229         ScheduledFuture<?> localPollFuture = pollFuture;
230         if (localPollFuture != null) {
231             localPollFuture.cancel(false);
232         }
233         scheduleGetUpdates(executions.isEmpty() ? thingConfig.getRefresh() : 2);
234     }
235
236     public synchronized void login() {
237         if (thingConfig.getEmail().isEmpty() || thingConfig.getPassword().isEmpty()) {
238             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
239                     "Can not access device as username and/or password are null");
240             return;
241         }
242
243         if (tooManyRequests) {
244             logger.debug("Skipping login due to too many requests");
245             return;
246         }
247
248         if (ThingStatus.ONLINE == thing.getStatus() && !reLoginNeeded) {
249             logger.debug("No need to log in, because already logged in");
250             return;
251         }
252
253         reLoginNeeded = false;
254         cloudFallback = false;
255
256         try {
257             String urlParameters = "";
258
259             // if cozytouch, must use oauth server
260             if (thingConfig.getCloudPortal().equalsIgnoreCase(COZYTOUCH_PORTAL)) {
261                 logger.debug("CozyTouch Oauth2 authentication flow");
262                 urlParameters = "jwt=" + loginCozytouch();
263             } else {
264                 urlParameters = "userId=" + urlEncode(thingConfig.getEmail()) + "&userPassword="
265                         + urlEncode(thingConfig.getPassword());
266             }
267
268             ContentResponse response = sendRequestBuilder("login", HttpMethod.POST)
269                     .content(new StringContentProvider(urlParameters),
270                             "application/x-www-form-urlencoded; charset=UTF-8")
271                     .send();
272
273             if (logger.isTraceEnabled()) {
274                 logger.trace("Login response: {}", response.getContentAsString());
275             }
276
277             SomfyTahomaLoginResponse data = gson.fromJson(response.getContentAsString(),
278                     SomfyTahomaLoginResponse.class);
279
280             if (data == null) {
281                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
282                         "Received invalid data (login)");
283             } else if (!data.getErrorCode().isEmpty()) {
284                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, data.getError());
285                 if (data.getError().startsWith(TOO_MANY_REQUESTS)) {
286                     setTooManyRequests();
287                 }
288             } else {
289                 if (thingConfig.isDevMode()) {
290                     initializeLocalMode();
291                 }
292
293                 String id = registerEvents();
294                 if (id != null && !UNAUTHORIZED.equals(id)) {
295                     eventsId = id;
296                     logger.debug("Events id: {}", eventsId);
297                     updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE,
298                             isDevModeReady() ? "LAN mode" : cloudFallback ? "Cloud mode fallback" : "Cloud mode");
299                 } else {
300                     logger.debug("Events id error: {}", id);
301                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
302                             "unable to register events");
303                 }
304             }
305         } catch (JsonSyntaxException e) {
306             logger.debug("Received invalid data (login)", e);
307             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Received invalid data (login)");
308         } catch (ExecutionException e) {
309             if (isOAuthGrantError(e)) {
310                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
311                         "Error logging in (check your credentials)");
312                 setTooManyRequests();
313             } else {
314                 logger.debug("Cannot get login cookie", e);
315                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Cannot get login cookie");
316             }
317         } catch (TimeoutException e) {
318             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Getting login cookie timeout");
319         } catch (InterruptedException e) {
320             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
321                     "Getting login cookie interrupted");
322             Thread.currentThread().interrupt();
323         }
324     }
325
326     public boolean isDevModeReady() {
327         return thingConfig.isDevMode() && !localToken.isEmpty() && !cloudFallback;
328     }
329
330     private void initializeLocalMode() {
331         if (thingConfig.getIp().isEmpty() || thingConfig.getPin().isEmpty()) {
332             discoverGateway();
333         }
334
335         if (!thingConfig.getIp().isEmpty() && !thingConfig.getPin().isEmpty()) {
336             try {
337                 if (thingConfig.getToken().isEmpty()) {
338                     localToken = getNewLocalToken();
339                     logger.debug("Local token retrieved");
340                     activateLocalToken();
341                     updateConfiguration();
342                 } else {
343                     localToken = thingConfig.getToken();
344                     activateLocalToken();
345                 }
346                 logger.debug("Local mode initialized, waiting for cloud sync");
347                 Thread.sleep(3000);
348             } catch (InterruptedException ex) {
349                 logger.debug("Interruption during local mode initialization, falling back to cloud mode", ex);
350                 Thread.currentThread().interrupt();
351             } catch (ExecutionException | TimeoutException ex) {
352                 logger.debug("Exception during local mode initialization, falling back to cloud mode", ex);
353                 cloudFallback = true;
354             }
355         } else {
356             logger.debug("Cannot switch to developer mode - gateway not found on LAN");
357             cloudFallback = true;
358         }
359     }
360
361     private String getNewLocalToken() throws ExecutionException, InterruptedException, TimeoutException {
362         // Get list of local tokens
363         SomfyTahomaLocalToken[] tokens = invokeCallToURL(
364                 CONFIG_URL + thingConfig.getPin() + LOCAL_TOKENS_URL + "devmode", "", HttpMethod.GET,
365                 SomfyTahomaLocalToken[].class);
366
367         // Delete old OH tokens
368         for (SomfyTahomaLocalToken token : tokens) {
369             if (OPENHAB_TOKEN.equals(token.getLabel())) {
370                 logger.debug("Deleting token: {}", token.getUuid());
371                 sendDeleteToTahomaWithCookie(CONFIG_URL + thingConfig.getPin() + LOCAL_TOKENS_URL + token.getUuid());
372             }
373         }
374
375         // Generate a new token
376         SomfyTahomaTokenReponse tokenResponse = invokeCallToURL(
377                 CONFIG_URL + thingConfig.getPin() + LOCAL_TOKENS_URL + "generate", "", HttpMethod.GET,
378                 SomfyTahomaTokenReponse.class);
379
380         return tokenResponse.getToken();
381     }
382
383     private void discoverGateway() {
384         logger.debug("Starting mDNS discovery...");
385         JmDNS jmdns = null;
386
387         try {
388             // Create a JmDNS instance
389             jmdns = JmDNS.create(InetAddress.getLocalHost());
390             jmdns.addServiceListener("_kizboxdev._tcp.local.", new SomfyTahomaMDNSDiscoveryListener(this));
391
392             // Wait a bit
393             Thread.sleep(TAHOMA_TIMEOUT * 1000);
394         } catch (InterruptedException e) {
395             logger.debug("mDNS discovery interrupted", e);
396             Thread.currentThread().interrupt();
397         } catch (IOException e) {
398             logger.debug("Exception during mDNS discovery", e);
399         }
400
401         if (jmdns != null) {
402             jmdns.unregisterAllServices();
403             try {
404                 jmdns.close();
405             } catch (IOException e) {
406                 // ignore
407             }
408         }
409     }
410
411     private void activateLocalToken() throws ExecutionException, InterruptedException, TimeoutException {
412         String param = "{\"label\" : \"" + OPENHAB_TOKEN + "\",\"token\" : \"" + localToken
413                 + "\",\"scope\" : \"devmode\"}";
414         String response = sendPostToTahomaWithCookie(CONFIG_URL + thingConfig.getPin() + "/local/tokens", param);
415         logger.trace("Local token activation: {}", response);
416     }
417
418     private void setTooManyRequests() {
419         if (!tooManyRequests) {
420             logger.debug(
421                     "Too many requests or bad credentials for the cloud portal, suspending activity for {} seconds",
422                     SUSPEND_TIME);
423             tooManyRequests = true;
424             loginFuture = scheduler.schedule(this::enableLogin, SUSPEND_TIME, TimeUnit.SECONDS);
425         }
426     }
427
428     private @Nullable String registerEvents() {
429         SomfyTahomaRegisterEventsResponse response = invokeCallToURL(EVENTS_URL + "register", "", HttpMethod.POST,
430                 SomfyTahomaRegisterEventsResponse.class);
431         return response != null ? response.getId() : null;
432     }
433
434     private String urlEncode(String text) {
435         return URLEncoder.encode(text, StandardCharsets.UTF_8);
436     }
437
438     private void enableLogin() {
439         tooManyRequests = false;
440     }
441
442     private List<SomfyTahomaEvent> getEvents() {
443         if (eventsId.isEmpty()) {
444             return List.of();
445         }
446
447         SomfyTahomaEvent[] response = invokeCallToURL(EVENTS_URL + eventsId + "/fetch", "", HttpMethod.POST,
448                 SomfyTahomaEvent[].class);
449         return response != null ? List.of(response) : List.of();
450     }
451
452     @Override
453     public void handleRemoval() {
454         super.handleRemoval();
455         logout();
456     }
457
458     @Override
459     public Collection<Class<? extends ThingHandlerService>> getServices() {
460         return Collections.singleton(SomfyTahomaItemDiscoveryService.class);
461     }
462
463     @Override
464     public void dispose() {
465         cleanup();
466         super.dispose();
467     }
468
469     private void cleanup() {
470         logger.debug("Doing cleanup");
471         stopPolling();
472         executions.clear();
473         // cancel all scheduled retries
474         retryFutures.forEach(x -> x.cancel(false));
475
476         ScheduledFuture<?> localLoginFuture = loginFuture;
477         if (localLoginFuture != null) {
478             localLoginFuture.cancel(true);
479             loginFuture = null;
480         }
481
482         HttpClient localHttpClient = httpClient;
483         if (localHttpClient != null) {
484             try {
485                 localHttpClient.stop();
486             } catch (Exception e) {
487                 logger.debug("Error during http client stopping", e);
488             }
489             httpClient = null;
490         }
491
492         // Clean access data
493         localToken = "";
494     }
495
496     @Override
497     public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
498         super.bridgeStatusChanged(bridgeStatusInfo);
499         if (ThingStatus.UNINITIALIZED == bridgeStatusInfo.getStatus()) {
500             cleanup();
501         }
502     }
503
504     /**
505      * Stops this thing's polling future
506      */
507     private void stopPolling() {
508         ScheduledFuture<?> localPollFuture = pollFuture;
509         if (localPollFuture != null) {
510             localPollFuture.cancel(true);
511             pollFuture = null;
512         }
513         ScheduledFuture<?> localStatusFuture = statusFuture;
514         if (localStatusFuture != null) {
515             localStatusFuture.cancel(true);
516             statusFuture = null;
517         }
518         ScheduledFuture<?> localReconciliationFuture = reconciliationFuture;
519         if (localReconciliationFuture != null) {
520             localReconciliationFuture.cancel(true);
521             reconciliationFuture = null;
522         }
523     }
524
525     public List<SomfyTahomaActionGroup> listActionGroups() {
526         SomfyTahomaActionGroup[] list = invokeCallToURL("actionGroups", "", HttpMethod.GET,
527                 SomfyTahomaActionGroup[].class);
528         return list != null ? List.of(list) : List.of();
529     }
530
531     public @Nullable SomfyTahomaSetup getSetup() {
532         SomfyTahomaSetup setup = invokeCallToURL("setup", "", HttpMethod.GET, SomfyTahomaSetup.class);
533         if (setup != null) {
534             saveDevicePlaces(setup.getDevices());
535         }
536         return setup;
537     }
538
539     public List<SomfyTahomaDevice> getDevices() {
540         SomfyTahomaDevice[] response = invokeCallToURL(SETUP_URL + "devices", "", HttpMethod.GET,
541                 SomfyTahomaDevice[].class);
542         List<SomfyTahomaDevice> devices = response != null ? List.of(response) : List.of();
543         saveDevicePlaces(devices);
544         return devices;
545     }
546
547     public synchronized @Nullable SomfyTahomaDevice getCachedDevice(String url) {
548         List<SomfyTahomaDevice> devices = cachedDevices.getValue();
549         if (devices != null) {
550             for (SomfyTahomaDevice device : devices) {
551                 if (url.equals(device.getDeviceURL())) {
552                     return device;
553                 }
554             }
555         }
556         return null;
557     }
558
559     private void saveDevicePlaces(List<SomfyTahomaDevice> devices) {
560         devicePlaces.clear();
561         for (SomfyTahomaDevice device : devices) {
562             if (!device.getPlaceOID().isEmpty()) {
563                 SomfyTahomaDevice newDevice = new SomfyTahomaDevice();
564                 newDevice.setPlaceOID(device.getPlaceOID());
565                 newDevice.getDefinition().setWidgetName(device.getDefinition().getWidgetName());
566                 devicePlaces.put(device.getDeviceURL(), newDevice);
567             }
568         }
569     }
570
571     private void getTahomaUpdates() {
572         logger.debug("Getting Tahoma Updates...");
573         if (ThingStatus.OFFLINE == thing.getStatus() && !reLogin()) {
574             return;
575         }
576
577         List<SomfyTahomaEvent> events = getEvents();
578         logger.trace("Got total of {} events", events.size());
579         for (SomfyTahomaEvent event : events) {
580             processEvent(event);
581         }
582     }
583
584     private void processEvent(SomfyTahomaEvent event) {
585         logger.debug("Got event: {}", event.getName());
586         switch (event.getName()) {
587             case "ExecutionRegisteredEvent":
588                 processExecutionRegisteredEvent(event);
589                 break;
590             case "ExecutionStateChangedEvent":
591                 processExecutionChangedEvent(event);
592                 break;
593             case "DeviceStateChangedEvent":
594                 processStateChangedEvent(event);
595                 break;
596             case "RefreshAllDevicesStatesCompletedEvent":
597                 scheduler.schedule(this::updateThings, 1, TimeUnit.SECONDS);
598                 break;
599             case "GatewayAliveEvent":
600             case "GatewayDownEvent":
601                 processGatewayEvent(event);
602                 break;
603             default:
604                 // ignore other states
605         }
606     }
607
608     private synchronized void updateThings() {
609         boolean needsUpdate = reconciliation;
610
611         for (Thing th : getThing().getThings()) {
612             if (th.isEnabled() && ThingStatus.ONLINE != th.getStatus()) {
613                 needsUpdate = true;
614             }
615         }
616
617         // update all states only if necessary
618         if (needsUpdate) {
619             updateAllStates();
620             reconciliation = false;
621         }
622     }
623
624     private void processExecutionRegisteredEvent(SomfyTahomaEvent event) {
625         boolean invalidData = false;
626         try {
627             JsonElement el = event.getAction();
628             if (el.isJsonArray()) {
629                 SomfyTahomaAction[] actions = gson.fromJson(el, SomfyTahomaAction[].class);
630                 if (actions == null) {
631                     invalidData = true;
632                 } else {
633                     for (SomfyTahomaAction action : actions) {
634                         registerExecution(action.getDeviceURL(), event.getExecId());
635                     }
636                 }
637             } else {
638                 SomfyTahomaAction action = gson.fromJson(el, SomfyTahomaAction.class);
639                 if (action == null) {
640                     invalidData = true;
641                 } else {
642                     registerExecution(action.getDeviceURL(), event.getExecId());
643                 }
644             }
645         } catch (JsonSyntaxException e) {
646             invalidData = true;
647         }
648         if (invalidData) {
649             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
650                     "Received invalid data (execution registered)");
651         }
652     }
653
654     private void processExecutionChangedEvent(SomfyTahomaEvent event) {
655         if (FAILED_EVENT.equals(event.getNewState()) || COMPLETED_EVENT.equals(event.getNewState())) {
656             logger.debug("Removing execution id: {}", event.getExecId());
657             unregisterExecution(event.getExecId());
658         }
659     }
660
661     private void registerExecution(String url, String execId) {
662         if (executions.containsKey(url)) {
663             executions.remove(url);
664             logger.debug("Previous execution exists for url: {}", url);
665         }
666         executions.put(url, execId);
667     }
668
669     private void unregisterExecution(String execId) {
670         if (executions.containsValue(execId)) {
671             executions.values().removeAll(Collections.singleton(execId));
672         } else {
673             logger.debug("Cannot remove execution id: {}, because it is not registered", execId);
674         }
675     }
676
677     private void processGatewayEvent(SomfyTahomaEvent event) {
678         // update gateway status
679         for (Thing th : getThing().getThings()) {
680             if (th.isEnabled() && THING_TYPE_GATEWAY.equals(th.getThingTypeUID())) {
681                 SomfyTahomaGatewayHandler gatewayHandler = (SomfyTahomaGatewayHandler) th.getHandler();
682                 if (gatewayHandler != null && gatewayHandler.getGateWayId().equals(event.getGatewayId())) {
683                     gatewayHandler.refresh(STATUS);
684                 }
685             }
686         }
687     }
688
689     private synchronized void updateAllStates() {
690         logger.debug("Updating all states");
691         getDevices().forEach(device -> updateDevice(device));
692     }
693
694     private void updateDevice(SomfyTahomaDevice device) {
695         String url = device.getDeviceURL();
696         List<SomfyTahomaState> states = device.getStates();
697         updateDevice(url, states);
698     }
699
700     private void updateDevice(String url, List<SomfyTahomaState> states) {
701         Thing th = getThingByDeviceUrl(url);
702         if (th == null) {
703             return;
704         }
705         SomfyTahomaBaseThingHandler handler = (SomfyTahomaBaseThingHandler) th.getHandler();
706         if (handler != null) {
707             handler.updateThingStatus(states);
708             handler.updateThingChannels(states);
709         }
710     }
711
712     private void processStateChangedEvent(SomfyTahomaEvent event) {
713         String deviceUrl = event.getDeviceUrl();
714         List<SomfyTahomaState> states = event.getDeviceStates();
715         logger.debug("States for device {} : {}", deviceUrl, states);
716         Thing thing = getThingByDeviceUrl(deviceUrl);
717
718         if (thing != null) {
719             logger.debug("Updating status of thing: {}", thing.getLabel());
720             SomfyTahomaBaseThingHandler handler = (SomfyTahomaBaseThingHandler) thing.getHandler();
721
722             if (handler != null) {
723                 // update thing status
724                 handler.updateThingStatus(states);
725                 handler.updateThingChannels(states);
726             }
727         } else {
728             logger.debug("Thing is disabled or handler is null, probably not bound thing.");
729         }
730     }
731
732     private void enableReconciliation() {
733         logger.debug("Enabling reconciliation");
734         reconciliation = true;
735     }
736
737     private void refreshTahomaStates() {
738         logger.debug("Refreshing Tahoma states...");
739         if (ThingStatus.OFFLINE == thing.getStatus() && !reLogin()) {
740             return;
741         }
742
743         // force Tahoma to ask for actual states
744         forceGatewaySync();
745     }
746
747     private @Nullable Thing getThingByDeviceUrl(String deviceUrl) {
748         for (Thing th : getThing().getThings()) {
749             if (!th.isEnabled()) {
750                 continue;
751             }
752             String url = (String) th.getConfiguration().get("url");
753             if (deviceUrl.equals(url)) {
754                 return th;
755             }
756         }
757         return null;
758     }
759
760     private void logout() {
761         try {
762             eventsId = "";
763             sendGetToTahomaWithCookie("logout");
764         } catch (ExecutionException | TimeoutException e) {
765             logger.debug("Cannot send logout command!", e);
766         } catch (InterruptedException e) {
767             Thread.currentThread().interrupt();
768         }
769     }
770
771     private String sendPostToTahomaWithCookie(String url, String urlParameters)
772             throws InterruptedException, ExecutionException, TimeoutException {
773         return sendMethodToTahomaWithCookie(url, HttpMethod.POST, urlParameters);
774     }
775
776     private String sendGetToTahomaWithCookie(String url)
777             throws InterruptedException, ExecutionException, TimeoutException {
778         return sendMethodToTahomaWithCookie(url, HttpMethod.GET);
779     }
780
781     private String sendPutToTahomaWithCookie(String url)
782             throws InterruptedException, ExecutionException, TimeoutException {
783         return sendMethodToTahomaWithCookie(url, HttpMethod.PUT);
784     }
785
786     private String sendDeleteToTahomaWithCookie(String url)
787             throws InterruptedException, ExecutionException, TimeoutException {
788         return sendMethodToTahomaWithCookie(url, HttpMethod.DELETE);
789     }
790
791     private String sendMethodToTahomaWithCookie(String url, HttpMethod method)
792             throws InterruptedException, ExecutionException, TimeoutException {
793         return sendMethodToTahomaWithCookie(url, method, "");
794     }
795
796     private String sendMethodToTahomaWithCookie(String url, HttpMethod method, String urlParameters)
797             throws InterruptedException, ExecutionException, TimeoutException {
798         logger.debug("Sending {} to url: {} with data: {}", method.asString(), getApiFullUrl(url), urlParameters);
799         Request request = sendRequestBuilder(url, method);
800         if (!urlParameters.isEmpty()) {
801             request = request.content(new StringContentProvider(urlParameters), "application/json");
802         }
803
804         ContentResponse response = request.send();
805
806         if (logger.isTraceEnabled()) {
807             logger.trace("Response: {}", response.getContentAsString());
808         }
809
810         if (response.getStatus() < 200 || response.getStatus() >= 300) {
811             logger.debug("Received unexpected status code: {}", response.getStatus());
812             if (response.getHeaders().contains(HttpHeader.CONTENT_TYPE)) {
813                 if (response.getHeaders().getField(HttpHeader.CONTENT_TYPE).getValue()
814                         .equalsIgnoreCase(MediaType.APPLICATION_JSON)) {
815                     try {
816                         SomfyTahomaError error = gson.fromJson(response.getContentAsString(), SomfyTahomaError.class);
817                         throw new ExecutionException(error.getError(), null);
818                     } catch (JsonSyntaxException e) {
819
820                     }
821                 }
822             }
823             throw new ExecutionException(
824                     "Unknown http error " + response.getStatus() + " while attempting to send a message.", null);
825         }
826         return response.getContentAsString();
827     }
828
829     private Request sendRequestBuilder(String subUrl, HttpMethod method) {
830         return isLocalRequest(subUrl) ? sendRequestBuilderLocal(subUrl, method)
831                 : sendRequestBuilderCloud(subUrl, method);
832     }
833
834     private boolean isLocalRequest(String subUrl) {
835         return isDevModeReady() && !subUrl.startsWith(CONFIG_URL);
836     }
837
838     private Request sendRequestBuilderCloud(String subUrl, HttpMethod method) {
839         return httpClient.newRequest(getApiFullUrl(subUrl)).method(method)
840                 .header(HttpHeader.ACCEPT_LANGUAGE, "en-US,en").header(HttpHeader.ACCEPT_ENCODING, "gzip, deflate")
841                 .header("X-Requested-With", "XMLHttpRequest").timeout(TAHOMA_TIMEOUT, TimeUnit.SECONDS)
842                 .agent(TAHOMA_AGENT);
843     }
844
845     private Request sendRequestBuilderLocal(String subUrl, HttpMethod method) {
846         return httpClient.newRequest(getApiFullUrl(subUrl)).method(method).accept("application/json")
847                 .header(HttpHeader.AUTHORIZATION, "Bearer " + localToken);
848     }
849
850     /**
851      * Performs the login for Cozytouch using OAUTH2 authorization.
852      *
853      * @return JSESSION ID cookie value.
854      * @throws ExecutionException
855      * @throws TimeoutException
856      * @throws InterruptedException
857      * @throws JsonSyntaxException
858      */
859     private String loginCozytouch()
860             throws InterruptedException, TimeoutException, ExecutionException, JsonSyntaxException {
861         String authBaseUrl = "https://" + COZYTOUCH_OAUTH2_URL;
862
863         String urlParameters = "grant_type=password&username=" + urlEncode(thingConfig.getEmail()) + "&password="
864                 + urlEncode(thingConfig.getPassword());
865
866         ContentResponse response = httpClient.newRequest(authBaseUrl + COZYTOUCH_OAUTH2_TOKEN_URL)
867                 .method(HttpMethod.POST).header(HttpHeader.ACCEPT_LANGUAGE, "en-US,en")
868                 .header(HttpHeader.ACCEPT_ENCODING, "gzip, deflate").header("X-Requested-With", "XMLHttpRequest")
869                 .header(HttpHeader.AUTHORIZATION, "Basic " + COZYTOUCH_OAUTH2_BASICAUTH)
870                 .timeout(TAHOMA_TIMEOUT, TimeUnit.SECONDS).agent(TAHOMA_AGENT)
871                 .content(new StringContentProvider(urlParameters), "application/x-www-form-urlencoded; charset=UTF-8")
872                 .send();
873
874         if (response.getStatus() != 200) {
875             // Login error
876             if (response.getHeaders().getField(HttpHeader.CONTENT_TYPE).getValue()
877                     .equalsIgnoreCase(MediaType.APPLICATION_JSON)) {
878                 try {
879                     SomfyTahomaOauth2Error error = gson.fromJson(response.getContentAsString(),
880                             SomfyTahomaOauth2Error.class);
881                     throw new ExecutionException(error.getErrorDescription(), null);
882                 } catch (JsonSyntaxException e) {
883
884                 }
885             }
886             throw new ExecutionException("Unknown error while attempting to log in.", null);
887         }
888
889         SomfyTahomaOauth2Reponse oauth2response = gson.fromJson(response.getContentAsString(),
890                 SomfyTahomaOauth2Reponse.class);
891
892         logger.debug("OAuth2 Access Token: {}", oauth2response.getAccessToken());
893
894         response = httpClient.newRequest(authBaseUrl + COZYTOUCH_OAUTH2_JWT_URL).method(HttpMethod.GET)
895                 .header(HttpHeader.AUTHORIZATION, "Bearer " + oauth2response.getAccessToken())
896                 .timeout(TAHOMA_TIMEOUT, TimeUnit.SECONDS).send();
897
898         if (response.getStatus() == 200) {
899             String jwt = response.getContentAsString();
900             return jwt.replace("\"", "");
901         } else {
902             throw new ExecutionException(String.format("Failed to retrieve JWT token. ResponseCode=%d, ResponseText=%s",
903                     response.getStatus(), response.getContentAsString()), null);
904         }
905     }
906
907     private String getApiFullUrl(String subUrl) {
908         return isLocalRequest(subUrl)
909                 ? "https://" + thingConfig.getIp() + ":8443/enduser-mobile-web/1/enduserAPI/" + subUrl
910                 : "https://" + thingConfig.getCloudPortal() + API_BASE_URL + subUrl;
911     }
912
913     public void sendCommand(String io, String command, String params, String url) {
914         if (ThingStatus.OFFLINE == thing.getStatus() && !reLogin()) {
915             return;
916         }
917
918         removeFinishedRetries();
919
920         boolean result = sendCommandInternal(io, command, params, url);
921         if (!result) {
922             scheduleRetry(io, command, params, url, thingConfig.getRetries());
923         }
924     }
925
926     private void repeatSendCommandInternal(String io, String command, String params, String url, int retries) {
927         logger.debug("Retrying command, retries left: {}", retries);
928         boolean result = sendCommandInternal(io, command, params, url);
929         if (!result && (retries > 0)) {
930             scheduleRetry(io, command, params, url, retries - 1);
931         }
932     }
933
934     private boolean sendCommandInternal(String io, String command, String params, String url) {
935         String value = "[]".equals(params) ? command : command + " " + params.replace("\"", "");
936         String urlParameters = "{\"label\":\"" + getThingLabelByURL(io) + " - " + value
937                 + " - openHAB\",\"actions\":[{\"deviceURL\":\"" + io + "\",\"commands\":[{\"name\":\"" + command
938                 + "\",\"parameters\":" + params + "}]}]}";
939         SomfyTahomaApplyResponse response = invokeCallToURL(url, urlParameters, HttpMethod.POST,
940                 SomfyTahomaApplyResponse.class);
941         if (response != null) {
942             if (!response.getExecId().isEmpty()) {
943                 logger.debug("Exec id: {}", response.getExecId());
944                 registerExecution(io, response.getExecId());
945                 scheduleNextGetUpdates();
946             } else {
947                 logger.debug("ExecId is empty!");
948                 return false;
949             }
950             return true;
951         }
952         return false;
953     }
954
955     private void removeFinishedRetries() {
956         retryFutures.removeIf(x -> x.isDone());
957         logger.debug("Currently {} retries are scheduled.", retryFutures.size());
958     }
959
960     private void scheduleRetry(String io, String command, String params, String url, int retries) {
961         retryFutures.add(scheduler.schedule(() -> {
962             repeatSendCommandInternal(io, command, params, url, retries);
963         }, thingConfig.getRetryDelay(), TimeUnit.MILLISECONDS));
964     }
965
966     public void sendCommandToSameDevicesInPlace(String io, String command, String params, String url) {
967         SomfyTahomaDevice device = devicePlaces.get(io);
968         if (device != null && !device.getPlaceOID().isEmpty()) {
969             devicePlaces.forEach((deviceUrl, devicePlace) -> {
970                 if (device.getPlaceOID().equals(devicePlace.getPlaceOID())
971                         && device.getDefinition().getWidgetName().equals(devicePlace.getDefinition().getWidgetName())) {
972                     sendCommand(deviceUrl, command, params, url);
973                 }
974             });
975         } else {
976             sendCommand(io, command, params, url);
977         }
978     }
979
980     private String getThingLabelByURL(String io) {
981         Thing th = getThingByDeviceUrl(io);
982         if (th != null) {
983             if (th.getProperties().containsKey(NAME_STATE)) {
984                 // Return label from Tahoma
985                 return th.getProperties().get(NAME_STATE).replace("\"", "");
986             }
987             // Return label from the thing
988             String label = th.getLabel();
989             return label != null ? label.replace("\"", "") : "";
990         }
991         return "null";
992     }
993
994     public @Nullable String getCurrentExecutions(String io) {
995         if (executions.containsKey(io)) {
996             return executions.get(io);
997         }
998         return null;
999     }
1000
1001     public void cancelExecution(String executionId) {
1002         invokeCallToURL(DELETE_URL + executionId, "", HttpMethod.DELETE, null);
1003     }
1004
1005     public void executeActionGroup(String id) {
1006         if (ThingStatus.OFFLINE == thing.getStatus() && !reLogin()) {
1007             return;
1008         }
1009         String execId = executeActionGroupInternal(id);
1010         if (execId == null) {
1011             execId = executeActionGroupInternal(id);
1012         }
1013         if (execId != null) {
1014             registerExecution(id, execId);
1015             scheduleNextGetUpdates();
1016         }
1017     }
1018
1019     private boolean reLogin() {
1020         logger.debug("Doing relogin");
1021         reLoginNeeded = true;
1022         localToken = "";
1023         login();
1024         return ThingStatus.OFFLINE != thing.getStatus();
1025     }
1026
1027     public @Nullable String executeActionGroupInternal(String id) {
1028         SomfyTahomaApplyResponse response = invokeCallToURL(EXEC_URL + id, "", HttpMethod.POST,
1029                 SomfyTahomaApplyResponse.class);
1030         if (response != null) {
1031             if (response.getExecId().isEmpty()) {
1032                 logger.debug("Got empty exec response");
1033                 return null;
1034             }
1035             return response.getExecId();
1036         }
1037         return null;
1038     }
1039
1040     public void forceGatewaySync() {
1041         // refresh is valid only if in a cloud mode
1042         if (!thingConfig.isDevMode() || localToken.isEmpty()) {
1043             invokeCallToURL(REFRESH_URL, "", HttpMethod.PUT, null);
1044         }
1045     }
1046
1047     public SomfyTahomaStatus getTahomaStatus(String gatewayId) {
1048         SomfyTahomaStatusResponse status = null;
1049
1050         if (isDevModeReady()) {
1051             // Local endpoint does not have a method for specific gateway
1052             SomfyTahomaStatusResponse[] data = invokeCallToURL(GATEWAYS_URL, "", HttpMethod.GET,
1053                     SomfyTahomaStatusResponse[].class);
1054             if (data != null) {
1055                 for (SomfyTahomaStatusResponse gatewayStatus : data) {
1056                     if (gatewayStatus.getGatewayId().equals(gatewayId)) {
1057                         status = gatewayStatus;
1058                         break;
1059                     }
1060                 }
1061             }
1062         } else {
1063             status = invokeCallToURL(GATEWAYS_URL + gatewayId, "", HttpMethod.GET, SomfyTahomaStatusResponse.class);
1064         }
1065
1066         if (status != null) {
1067             logger.debug("Tahoma status: {}", status.getConnectivity().getStatus());
1068             logger.debug("Tahoma protocol version: {}", status.getConnectivity().getProtocolVersion());
1069             return status.getConnectivity();
1070         }
1071         return new SomfyTahomaStatus();
1072     }
1073
1074     private boolean isTempBanned(Exception ex) {
1075         String msg = ex.getMessage();
1076         return msg != null && msg.contains(TEMPORARILY_BANNED);
1077     }
1078
1079     private boolean isEventListenerTimeout(Exception ex) {
1080         String msg = ex.getMessage();
1081         return msg != null && msg.contains(EVENT_LISTENER_TIMEOUT);
1082     }
1083
1084     private boolean isOAuthGrantError(Exception ex) {
1085         String msg = ex.getMessage();
1086         return msg != null
1087                 && (msg.contains(AUTHENTICATION_OAUTH_GRANT_ERROR) || msg.contains(AUTHENTICATION_OAUTH_INVALID_GRANT));
1088     }
1089
1090     @Override
1091     public void handleConfigurationUpdate(Map<String, Object> configurationParameters) {
1092         super.handleConfigurationUpdate(configurationParameters);
1093         if (configurationParameters.containsKey("email") || configurationParameters.containsKey("password")
1094                 || configurationParameters.containsKey("portalUrl")) {
1095             reLoginNeeded = true;
1096             tooManyRequests = false;
1097         }
1098     }
1099
1100     public synchronized void refresh(String url, String stateName) {
1101         SomfyTahomaState state = invokeCallToURL(DEVICES_URL + urlEncode(url) + "/states/" + stateName, "",
1102                 HttpMethod.GET, SomfyTahomaState.class);
1103         if (state != null && !state.getName().isEmpty()) {
1104             updateDevice(url, List.of(state));
1105         }
1106     }
1107
1108     private @Nullable <T> T invokeCallToURL(String url, String urlParameters, HttpMethod method,
1109             @Nullable Class<T> classOfT) {
1110         String response = "";
1111         try {
1112             switch (method) {
1113                 case GET:
1114                     response = sendGetToTahomaWithCookie(url);
1115                     break;
1116                 case PUT:
1117                     response = sendPutToTahomaWithCookie(url);
1118                     break;
1119                 case POST:
1120                     response = sendPostToTahomaWithCookie(url, urlParameters);
1121                     break;
1122                 case DELETE:
1123                     response = sendDeleteToTahomaWithCookie(url);
1124                 default:
1125             }
1126             return classOfT != null ? gson.fromJson(response, classOfT) : null;
1127         } catch (JsonSyntaxException e) {
1128             logger.debug("Received data: {} is not JSON", response, e);
1129             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Received invalid data");
1130         } catch (ExecutionException e) {
1131             if (isTempBanned(e)) {
1132                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Temporarily banned");
1133                 setTooManyRequests();
1134             } else if (isEventListenerTimeout(e)) {
1135                 reLogin();
1136             } else {
1137                 logger.debug("Cannot call url: {} with params: {}!", getApiFullUrl(url), urlParameters, e);
1138                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
1139             }
1140         } catch (TimeoutException e) {
1141             logger.debug("Timeout when calling url: {} with params: {}!", getApiFullUrl(url), urlParameters, e);
1142             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
1143         } catch (InterruptedException e) {
1144             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
1145             Thread.currentThread().interrupt();
1146         }
1147         return null;
1148     }
1149
1150     public void setGatewayIPAddress(String gatewayIPAddress) {
1151         thingConfig.setIp(gatewayIPAddress);
1152     }
1153
1154     public void setGatewayPin(String gatewayPin) {
1155         thingConfig.setPin(gatewayPin);
1156     }
1157
1158     public void updateConfiguration() {
1159         Configuration config = editConfiguration();
1160         config.put("ip", thingConfig.getIp());
1161         config.put("pin", thingConfig.getPin());
1162         if (!localToken.isEmpty()) {
1163             config.put("token", localToken);
1164         }
1165         updateConfiguration(config);
1166     }
1167 }