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