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