]> git.basschouten.com Git - openhab-addons.git/blob
664530bf93e2f6192eb0fa34076929965fd5c794
[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.time.Instant;
23 import java.util.Collection;
24 import java.util.Collections;
25 import java.util.HashMap;
26 import java.util.List;
27 import java.util.Map;
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<ScheduledFuture<?>>();
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                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
311                             "unable to register events");
312                 }
313             }
314         } catch (JsonSyntaxException e) {
315             logger.debug("Received invalid data (login)", e);
316             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Received invalid data (login)");
317         } catch (ExecutionException e) {
318             if (isOAuthGrantError(e)) {
319                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
320                         "Error logging in (check your credentials)");
321                 setTooManyRequests();
322             } else {
323                 logger.debug("Cannot get login cookie", e);
324                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Cannot get login cookie");
325             }
326         } catch (TimeoutException e) {
327             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Getting login cookie timeout");
328         } catch (InterruptedException e) {
329             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
330                     "Getting login cookie interrupted");
331             Thread.currentThread().interrupt();
332         }
333     }
334
335     public boolean isDevModeReady() {
336         return thingConfig.isDevMode() && !localToken.isEmpty() && !cloudFallback;
337     }
338
339     private void initializeLocalMode() {
340         if (thingConfig.getIp().isEmpty() || thingConfig.getPin().isEmpty()) {
341             discoverGateway();
342         }
343
344         if (!thingConfig.getIp().isEmpty() && !thingConfig.getPin().isEmpty()) {
345             try {
346                 if (thingConfig.getToken().isEmpty()) {
347                     localToken = getNewLocalToken();
348                     logger.debug("Local token retrieved");
349                     activateLocalToken();
350                     updateConfiguration();
351                 } else {
352                     localToken = thingConfig.getToken();
353                     activateLocalToken();
354                 }
355                 logger.debug("Local mode initialized, waiting for cloud sync");
356                 Thread.sleep(3000);
357             } catch (InterruptedException ex) {
358                 logger.debug("Interruption during local mode initialization, falling back to cloud mode", ex);
359                 Thread.currentThread().interrupt();
360             } catch (ExecutionException | TimeoutException ex) {
361                 logger.debug("Exception during local mode initialization, falling back to cloud mode", ex);
362                 cloudFallback = true;
363             }
364         } else {
365             logger.debug("Cannot switch to developer mode - gateway not found on LAN");
366             cloudFallback = true;
367         }
368     }
369
370     private String getNewLocalToken() throws ExecutionException, InterruptedException, TimeoutException {
371         // Get list of local tokens
372         SomfyTahomaLocalToken[] tokens = invokeCallToURL(
373                 CONFIG_URL + thingConfig.getPin() + LOCAL_TOKENS_URL + "devmode", "", HttpMethod.GET,
374                 SomfyTahomaLocalToken[].class);
375
376         // Delete old OH tokens
377         for (SomfyTahomaLocalToken token : tokens) {
378             if (OPENHAB_TOKEN.equals(token.getLabel())) {
379                 logger.debug("Deleting token: {}", token.getUuid());
380                 sendDeleteToTahomaWithCookie(CONFIG_URL + thingConfig.getPin() + LOCAL_TOKENS_URL + token.getUuid());
381             }
382         }
383
384         // Generate a new token
385         SomfyTahomaTokenReponse tokenResponse = invokeCallToURL(
386                 CONFIG_URL + thingConfig.getPin() + LOCAL_TOKENS_URL + "generate", "", HttpMethod.GET,
387                 SomfyTahomaTokenReponse.class);
388
389         return tokenResponse.getToken();
390     }
391
392     private void discoverGateway() {
393         logger.debug("Starting mDNS discovery...");
394         JmDNS jmdns = null;
395
396         try {
397             // Create a JmDNS instance
398             jmdns = JmDNS.create(InetAddress.getLocalHost());
399             jmdns.addServiceListener("_kizboxdev._tcp.local.", new SomfyTahomaMDNSDiscoveryListener(this));
400
401             // Wait a bit
402             Thread.sleep(TAHOMA_TIMEOUT * 1000);
403         } catch (InterruptedException e) {
404             logger.debug("mDNS discovery interrupted", e);
405             Thread.currentThread().interrupt();
406         } catch (IOException e) {
407             logger.debug("Exception during mDNS discovery", e);
408         }
409
410         if (jmdns != null) {
411             jmdns.unregisterAllServices();
412             try {
413                 jmdns.close();
414             } catch (IOException e) {
415                 // ignore
416             }
417         }
418     }
419
420     private void activateLocalToken() throws ExecutionException, InterruptedException, TimeoutException {
421         String param = "{\"label\" : \"" + OPENHAB_TOKEN + "\",\"token\" : \"" + localToken
422                 + "\",\"scope\" : \"devmode\"}";
423         String response = sendPostToTahomaWithCookie(CONFIG_URL + thingConfig.getPin() + "/local/tokens", param);
424         logger.trace("Local token activation: {}", response);
425     }
426
427     private void setTooManyRequests() {
428         if (!tooManyRequests) {
429             logger.debug(
430                     "Too many requests or bad credentials for the cloud portal, suspending activity for {} seconds",
431                     SUSPEND_TIME);
432             tooManyRequests = true;
433             loginFuture = scheduler.schedule(this::enableLogin, SUSPEND_TIME, TimeUnit.SECONDS);
434         }
435     }
436
437     private @Nullable String registerEvents() {
438         SomfyTahomaRegisterEventsResponse response = invokeCallToURL(EVENTS_URL + "register", "", HttpMethod.POST,
439                 SomfyTahomaRegisterEventsResponse.class);
440         return response != null ? response.getId() : null;
441     }
442
443     private String urlEncode(String text) {
444         return URLEncoder.encode(text, StandardCharsets.UTF_8);
445     }
446
447     private void enableLogin() {
448         tooManyRequests = false;
449     }
450
451     private List<SomfyTahomaEvent> getEvents() {
452         if (eventsId.isEmpty()) {
453             return List.of();
454         }
455
456         SomfyTahomaEvent[] response = invokeCallToURL(EVENTS_URL + eventsId + "/fetch", "", HttpMethod.POST,
457                 SomfyTahomaEvent[].class);
458         return response != null ? List.of(response) : List.of();
459     }
460
461     @Override
462     public void handleRemoval() {
463         super.handleRemoval();
464         logout();
465     }
466
467     @Override
468     public Collection<Class<? extends ThingHandlerService>> getServices() {
469         return Collections.singleton(SomfyTahomaItemDiscoveryService.class);
470     }
471
472     @Override
473     public void dispose() {
474         cleanup();
475         super.dispose();
476     }
477
478     private void cleanup() {
479         logger.debug("Doing cleanup");
480         stopPolling();
481         executions.clear();
482         // cancel all scheduled retries
483         retryFutures.forEach(x -> x.cancel(false));
484
485         ScheduledFuture<?> localLoginFuture = loginFuture;
486         if (localLoginFuture != null) {
487             localLoginFuture.cancel(true);
488             loginFuture = null;
489         }
490
491         HttpClient localHttpClient = httpClient;
492         if (localHttpClient != null) {
493             try {
494                 localHttpClient.stop();
495             } catch (Exception e) {
496                 logger.debug("Error during http client stopping", e);
497             }
498             httpClient = null;
499         }
500
501         // Clean access data
502         localToken = "";
503     }
504
505     @Override
506     public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
507         super.bridgeStatusChanged(bridgeStatusInfo);
508         if (ThingStatus.UNINITIALIZED == bridgeStatusInfo.getStatus()) {
509             cleanup();
510         }
511     }
512
513     /**
514      * Stops this thing's polling future
515      */
516     private void stopPolling() {
517         ScheduledFuture<?> localPollFuture = pollFuture;
518         if (localPollFuture != null) {
519             localPollFuture.cancel(true);
520             pollFuture = null;
521         }
522         ScheduledFuture<?> localStatusFuture = statusFuture;
523         if (localStatusFuture != null) {
524             localStatusFuture.cancel(true);
525             statusFuture = null;
526         }
527         ScheduledFuture<?> localReconciliationFuture = reconciliationFuture;
528         if (localReconciliationFuture != null) {
529             localReconciliationFuture.cancel(true);
530             reconciliationFuture = null;
531         }
532     }
533
534     public List<SomfyTahomaActionGroup> listActionGroups() {
535         SomfyTahomaActionGroup[] list = invokeCallToURL("actionGroups", "", HttpMethod.GET,
536                 SomfyTahomaActionGroup[].class);
537         return list != null ? List.of(list) : List.of();
538     }
539
540     public @Nullable SomfyTahomaSetup getSetup() {
541         SomfyTahomaSetup setup = invokeCallToURL("setup", "", HttpMethod.GET, SomfyTahomaSetup.class);
542         if (setup != null) {
543             saveDevicePlaces(setup.getDevices());
544         }
545         return setup;
546     }
547
548     public List<SomfyTahomaDevice> getDevices() {
549         SomfyTahomaDevice[] response = invokeCallToURL(SETUP_URL + "devices", "", HttpMethod.GET,
550                 SomfyTahomaDevice[].class);
551         List<SomfyTahomaDevice> devices = response != null ? List.of(response) : List.of();
552         saveDevicePlaces(devices);
553         return devices;
554     }
555
556     public synchronized @Nullable SomfyTahomaDevice getCachedDevice(String url) {
557         List<SomfyTahomaDevice> devices = cachedDevices.getValue();
558         if (devices != null) {
559             for (SomfyTahomaDevice device : devices) {
560                 if (url.equals(device.getDeviceURL())) {
561                     return device;
562                 }
563             }
564         }
565         return null;
566     }
567
568     private void saveDevicePlaces(List<SomfyTahomaDevice> devices) {
569         devicePlaces.clear();
570         for (SomfyTahomaDevice device : devices) {
571             if (!device.getPlaceOID().isEmpty()) {
572                 SomfyTahomaDevice newDevice = new SomfyTahomaDevice();
573                 newDevice.setPlaceOID(device.getPlaceOID());
574                 newDevice.getDefinition().setWidgetName(device.getDefinition().getWidgetName());
575                 devicePlaces.put(device.getDeviceURL(), newDevice);
576             }
577         }
578     }
579
580     private void getTahomaUpdates() {
581         logger.debug("Getting Tahoma Updates...");
582         if (ThingStatus.OFFLINE == thing.getStatus() && !reLogin()) {
583             return;
584         }
585
586         List<SomfyTahomaEvent> events = getEvents();
587         logger.trace("Got total of {} events", events.size());
588         for (SomfyTahomaEvent event : events) {
589             processEvent(event);
590         }
591     }
592
593     private void processEvent(SomfyTahomaEvent event) {
594         logger.debug("Got event: {}", event.getName());
595         switch (event.getName()) {
596             case "ExecutionRegisteredEvent":
597                 processExecutionRegisteredEvent(event);
598                 break;
599             case "ExecutionStateChangedEvent":
600                 processExecutionChangedEvent(event);
601                 break;
602             case "DeviceStateChangedEvent":
603                 processStateChangedEvent(event);
604                 break;
605             case "RefreshAllDevicesStatesCompletedEvent":
606                 scheduler.schedule(this::updateThings, 1, TimeUnit.SECONDS);
607                 break;
608             case "GatewayAliveEvent":
609             case "GatewayDownEvent":
610                 processGatewayEvent(event);
611                 break;
612             default:
613                 // ignore other states
614         }
615     }
616
617     private synchronized void updateThings() {
618         boolean needsUpdate = reconciliation;
619
620         for (Thing th : getThing().getThings()) {
621             if (th.isEnabled() && ThingStatus.ONLINE != th.getStatus()) {
622                 needsUpdate = true;
623             }
624         }
625
626         // update all states only if necessary
627         if (needsUpdate) {
628             updateAllStates();
629             reconciliation = false;
630         }
631     }
632
633     private void processExecutionRegisteredEvent(SomfyTahomaEvent event) {
634         boolean invalidData = false;
635         try {
636             JsonElement el = event.getAction();
637             if (el.isJsonArray()) {
638                 SomfyTahomaAction[] actions = gson.fromJson(el, SomfyTahomaAction[].class);
639                 if (actions == null) {
640                     invalidData = true;
641                 } else {
642                     for (SomfyTahomaAction action : actions) {
643                         registerExecution(action.getDeviceURL(), event.getExecId());
644                     }
645                 }
646             } else {
647                 SomfyTahomaAction action = gson.fromJson(el, SomfyTahomaAction.class);
648                 if (action == null) {
649                     invalidData = true;
650                 } else {
651                     registerExecution(action.getDeviceURL(), event.getExecId());
652                 }
653             }
654         } catch (JsonSyntaxException e) {
655             invalidData = true;
656         }
657         if (invalidData) {
658             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
659                     "Received invalid data (execution registered)");
660         }
661     }
662
663     private void processExecutionChangedEvent(SomfyTahomaEvent event) {
664         if (FAILED_EVENT.equals(event.getNewState()) || COMPLETED_EVENT.equals(event.getNewState())) {
665             logger.debug("Removing execution id: {}", event.getExecId());
666             unregisterExecution(event.getExecId());
667         }
668     }
669
670     private void registerExecution(String url, String execId) {
671         if (executions.containsKey(url)) {
672             executions.remove(url);
673             logger.debug("Previous execution exists for url: {}", url);
674         }
675         executions.put(url, execId);
676     }
677
678     private void unregisterExecution(String execId) {
679         if (executions.containsValue(execId)) {
680             executions.values().removeAll(Collections.singleton(execId));
681         } else {
682             logger.debug("Cannot remove execution id: {}, because it is not registered", execId);
683         }
684     }
685
686     private void processGatewayEvent(SomfyTahomaEvent event) {
687         // update gateway status
688         for (Thing th : getThing().getThings()) {
689             if (th.isEnabled() && THING_TYPE_GATEWAY.equals(th.getThingTypeUID())) {
690                 SomfyTahomaGatewayHandler gatewayHandler = (SomfyTahomaGatewayHandler) th.getHandler();
691                 if (gatewayHandler != null && gatewayHandler.getGateWayId().equals(event.getGatewayId())) {
692                     gatewayHandler.refresh(STATUS);
693                 }
694             }
695         }
696     }
697
698     private synchronized void updateAllStates() {
699         logger.debug("Updating all states");
700         getDevices().forEach(device -> updateDevice(device));
701     }
702
703     private void updateDevice(SomfyTahomaDevice device) {
704         String url = device.getDeviceURL();
705         List<SomfyTahomaState> states = device.getStates();
706         updateDevice(url, states);
707     }
708
709     private void updateDevice(String url, List<SomfyTahomaState> states) {
710         Thing th = getThingByDeviceUrl(url);
711         if (th == null) {
712             return;
713         }
714         SomfyTahomaBaseThingHandler handler = (SomfyTahomaBaseThingHandler) th.getHandler();
715         if (handler != null) {
716             handler.updateThingStatus(states);
717             handler.updateThingChannels(states);
718         }
719     }
720
721     private void processStateChangedEvent(SomfyTahomaEvent event) {
722         String deviceUrl = event.getDeviceUrl();
723         List<SomfyTahomaState> states = event.getDeviceStates();
724         logger.debug("States for device {} : {}", deviceUrl, states);
725         Thing thing = getThingByDeviceUrl(deviceUrl);
726
727         if (thing != null) {
728             logger.debug("Updating status of thing: {}", thing.getLabel());
729             SomfyTahomaBaseThingHandler handler = (SomfyTahomaBaseThingHandler) thing.getHandler();
730
731             if (handler != null) {
732                 // update thing status
733                 handler.updateThingStatus(states);
734                 handler.updateThingChannels(states);
735             }
736         } else {
737             logger.debug("Thing is disabled or handler is null, probably not bound thing.");
738         }
739     }
740
741     private void enableReconciliation() {
742         logger.debug("Enabling reconciliation");
743         reconciliation = true;
744     }
745
746     private void refreshTahomaStates() {
747         logger.debug("Refreshing Tahoma states...");
748         if (ThingStatus.OFFLINE == thing.getStatus() && !reLogin()) {
749             return;
750         }
751
752         // force Tahoma to ask for actual states
753         forceGatewaySync();
754     }
755
756     private @Nullable Thing getThingByDeviceUrl(String deviceUrl) {
757         for (Thing th : getThing().getThings()) {
758             if (!th.isEnabled()) {
759                 continue;
760             }
761             String url = (String) th.getConfiguration().get("url");
762             if (deviceUrl.equals(url)) {
763                 return th;
764             }
765         }
766         return null;
767     }
768
769     private void logout() {
770         try {
771             eventsId = "";
772             sendGetToTahomaWithCookie("logout");
773         } catch (ExecutionException | TimeoutException e) {
774             logger.debug("Cannot send logout command!", e);
775         } catch (InterruptedException e) {
776             Thread.currentThread().interrupt();
777         }
778     }
779
780     private String sendPostToTahomaWithCookie(String url, String urlParameters)
781             throws InterruptedException, ExecutionException, TimeoutException {
782         return sendMethodToTahomaWithCookie(url, HttpMethod.POST, urlParameters);
783     }
784
785     private String sendGetToTahomaWithCookie(String url)
786             throws InterruptedException, ExecutionException, TimeoutException {
787         return sendMethodToTahomaWithCookie(url, HttpMethod.GET);
788     }
789
790     private String sendPutToTahomaWithCookie(String url)
791             throws InterruptedException, ExecutionException, TimeoutException {
792         return sendMethodToTahomaWithCookie(url, HttpMethod.PUT);
793     }
794
795     private String sendDeleteToTahomaWithCookie(String url)
796             throws InterruptedException, ExecutionException, TimeoutException {
797         return sendMethodToTahomaWithCookie(url, HttpMethod.DELETE);
798     }
799
800     private String sendMethodToTahomaWithCookie(String url, HttpMethod method)
801             throws InterruptedException, ExecutionException, TimeoutException {
802         return sendMethodToTahomaWithCookie(url, method, "");
803     }
804
805     private String sendMethodToTahomaWithCookie(String url, HttpMethod method, String urlParameters)
806             throws InterruptedException, ExecutionException, TimeoutException {
807         logger.debug("Sending {} to url: {} with data: {}", method.asString(), getApiFullUrl(url), urlParameters);
808         Request request = sendRequestBuilder(url, method);
809         if (!urlParameters.isEmpty()) {
810             request = request.content(new StringContentProvider(urlParameters), "application/json");
811         }
812
813         ContentResponse response = request.send();
814
815         if (logger.isTraceEnabled()) {
816             logger.trace("Response: {}", response.getContentAsString());
817         }
818
819         if (response.getStatus() < 200 || response.getStatus() >= 300) {
820             logger.debug("Received unexpected status code: {}", response.getStatus());
821             if (response.getHeaders().contains(HttpHeader.CONTENT_TYPE)) {
822                 if (response.getHeaders().getField(HttpHeader.CONTENT_TYPE).getValue()
823                         .equalsIgnoreCase(MediaType.APPLICATION_JSON)) {
824                     try {
825                         SomfyTahomaError error = gson.fromJson(response.getContentAsString(), SomfyTahomaError.class);
826                         throw new ExecutionException(error.getError(), null);
827                     } catch (JsonSyntaxException e) {
828
829                     }
830                 }
831             }
832             throw new ExecutionException(
833                     "Unknown http error " + response.getStatus() + " while attempting to send a message.", null);
834         }
835         return response.getContentAsString();
836     }
837
838     private Request sendRequestBuilder(String subUrl, HttpMethod method) {
839         return isLocalRequest(subUrl) ? sendRequestBuilderLocal(subUrl, method)
840                 : sendRequestBuilderCloud(subUrl, method);
841     }
842
843     private boolean isLocalRequest(String subUrl) {
844         return isDevModeReady() && !subUrl.startsWith(CONFIG_URL);
845     }
846
847     private Request sendRequestBuilderCloud(String subUrl, HttpMethod method) {
848         return httpClient.newRequest(getApiFullUrl(subUrl)).method(method)
849                 .header(HttpHeader.ACCEPT_LANGUAGE, "en-US,en").header(HttpHeader.ACCEPT_ENCODING, "gzip, deflate")
850                 .header("X-Requested-With", "XMLHttpRequest").timeout(TAHOMA_TIMEOUT, TimeUnit.SECONDS)
851                 .agent(TAHOMA_AGENT);
852     }
853
854     private Request sendRequestBuilderLocal(String subUrl, HttpMethod method) {
855         return httpClient.newRequest(getApiFullUrl(subUrl)).method(method).accept("application/json")
856                 .timeout(TAHOMA_TIMEOUT, TimeUnit.SECONDS).header(HttpHeader.AUTHORIZATION, "Bearer " + localToken);
857     }
858
859     /**
860      * Performs the login for Cozytouch using OAUTH2 authorization.
861      *
862      * @return JSESSION ID cookie value.
863      * @throws ExecutionException
864      * @throws TimeoutException
865      * @throws InterruptedException
866      * @throws JsonSyntaxException
867      */
868     private String loginCozytouch()
869             throws InterruptedException, TimeoutException, ExecutionException, JsonSyntaxException {
870         String authBaseUrl = "https://" + COZYTOUCH_OAUTH2_URL;
871
872         String urlParameters = "grant_type=password&username=" + urlEncode(thingConfig.getEmail()) + "&password="
873                 + urlEncode(thingConfig.getPassword());
874
875         ContentResponse response = httpClient.newRequest(authBaseUrl + COZYTOUCH_OAUTH2_TOKEN_URL)
876                 .method(HttpMethod.POST).header(HttpHeader.ACCEPT_LANGUAGE, "en-US,en")
877                 .header(HttpHeader.ACCEPT_ENCODING, "gzip, deflate").header("X-Requested-With", "XMLHttpRequest")
878                 .header(HttpHeader.AUTHORIZATION, "Basic " + COZYTOUCH_OAUTH2_BASICAUTH)
879                 .timeout(TAHOMA_TIMEOUT, TimeUnit.SECONDS).agent(TAHOMA_AGENT)
880                 .content(new StringContentProvider(urlParameters), "application/x-www-form-urlencoded; charset=UTF-8")
881                 .send();
882
883         if (response.getStatus() != 200) {
884             // Login error
885             if (response.getHeaders().getField(HttpHeader.CONTENT_TYPE).getValue()
886                     .equalsIgnoreCase(MediaType.APPLICATION_JSON)) {
887                 try {
888                     SomfyTahomaOauth2Error error = gson.fromJson(response.getContentAsString(),
889                             SomfyTahomaOauth2Error.class);
890                     throw new ExecutionException(error.getErrorDescription(), null);
891                 } catch (JsonSyntaxException e) {
892
893                 }
894             }
895             throw new ExecutionException("Unknown error while attempting to log in.", null);
896         }
897
898         SomfyTahomaOauth2Reponse oauth2response = gson.fromJson(response.getContentAsString(),
899                 SomfyTahomaOauth2Reponse.class);
900
901         logger.debug("OAuth2 Access Token: {}", oauth2response.getAccessToken());
902
903         response = httpClient.newRequest(authBaseUrl + COZYTOUCH_OAUTH2_JWT_URL).method(HttpMethod.GET)
904                 .header(HttpHeader.AUTHORIZATION, "Bearer " + oauth2response.getAccessToken())
905                 .timeout(TAHOMA_TIMEOUT, TimeUnit.SECONDS).send();
906
907         if (response.getStatus() == 200) {
908             String jwt = response.getContentAsString();
909             return jwt.replace("\"", "");
910         } else {
911             throw new ExecutionException(String.format("Failed to retrieve JWT token. ResponseCode=%d, ResponseText=%s",
912                     response.getStatus(), response.getContentAsString()), null);
913         }
914     }
915
916     private String getApiFullUrl(String subUrl) {
917         return isLocalRequest(subUrl)
918                 ? "https://" + thingConfig.getIp() + ":8443/enduser-mobile-web/1/enduserAPI/" + subUrl
919                 : "https://" + thingConfig.getCloudPortal() + API_BASE_URL + subUrl;
920     }
921
922     public void sendCommand(String io, String command, String params, String url) {
923         if (ThingStatus.OFFLINE == thing.getStatus() && !reLogin()) {
924             return;
925         }
926
927         removeFinishedRetries();
928
929         boolean result = sendCommandInternal(io, command, params, url);
930         if (!result) {
931             scheduleRetry(io, command, params, url, thingConfig.getRetries());
932         }
933     }
934
935     private void repeatSendCommandInternal(String io, String command, String params, String url, int retries) {
936         logger.debug("Retrying command, retries left: {}", retries);
937         boolean result = sendCommandInternal(io, command, params, url);
938         if (!result && (retries > 0)) {
939             scheduleRetry(io, command, params, url, retries - 1);
940         }
941     }
942
943     private boolean sendCommandInternal(String io, String command, String params, String url) {
944         String value = "[]".equals(params) ? command : command + " " + params.replace("\"", "");
945         String urlParameters = "{\"label\":\"" + getThingLabelByURL(io) + " - " + value
946                 + " - openHAB\",\"actions\":[{\"deviceURL\":\"" + io + "\",\"commands\":[{\"name\":\"" + command
947                 + "\",\"parameters\":" + params + "}]}]}";
948         SomfyTahomaApplyResponse response = invokeCallToURL(url, urlParameters, HttpMethod.POST,
949                 SomfyTahomaApplyResponse.class);
950         if (response != null) {
951             if (!response.getExecId().isEmpty()) {
952                 logger.debug("Exec id: {}", response.getExecId());
953                 registerExecution(io, response.getExecId());
954                 scheduleNextGetUpdates();
955             } else {
956                 logger.debug("ExecId is empty!");
957                 return false;
958             }
959             return true;
960         }
961         return false;
962     }
963
964     private void removeFinishedRetries() {
965         retryFutures.removeIf(x -> x.isDone());
966         logger.debug("Currently {} retries are scheduled.", retryFutures.size());
967     }
968
969     private void scheduleRetry(String io, String command, String params, String url, int retries) {
970         retryFutures.add(scheduler.schedule(() -> {
971             repeatSendCommandInternal(io, command, params, url, retries);
972         }, thingConfig.getRetryDelay(), TimeUnit.MILLISECONDS));
973     }
974
975     public void sendCommandToSameDevicesInPlace(String io, String command, String params, String url) {
976         SomfyTahomaDevice device = devicePlaces.get(io);
977         if (device != null && !device.getPlaceOID().isEmpty()) {
978             devicePlaces.forEach((deviceUrl, devicePlace) -> {
979                 if (device.getPlaceOID().equals(devicePlace.getPlaceOID())
980                         && device.getDefinition().getWidgetName().equals(devicePlace.getDefinition().getWidgetName())) {
981                     sendCommand(deviceUrl, command, params, url);
982                 }
983             });
984         } else {
985             sendCommand(io, command, params, url);
986         }
987     }
988
989     private String getThingLabelByURL(String io) {
990         Thing th = getThingByDeviceUrl(io);
991         if (th != null) {
992             if (th.getProperties().containsKey(NAME_STATE)) {
993                 // Return label from Tahoma
994                 return th.getProperties().get(NAME_STATE).replace("\"", "");
995             }
996             // Return label from the thing
997             String label = th.getLabel();
998             return label != null ? label.replace("\"", "") : "";
999         }
1000         return "null";
1001     }
1002
1003     public @Nullable String getCurrentExecutions(String io) {
1004         if (executions.containsKey(io)) {
1005             return executions.get(io);
1006         }
1007         return null;
1008     }
1009
1010     public void cancelExecution(String executionId) {
1011         invokeCallToURL(DELETE_URL + executionId, "", HttpMethod.DELETE, null);
1012     }
1013
1014     public void executeActionGroup(String id) {
1015         if (ThingStatus.OFFLINE == thing.getStatus() && !reLogin()) {
1016             return;
1017         }
1018         String execId = executeActionGroupInternal(id);
1019         if (execId == null) {
1020             execId = executeActionGroupInternal(id);
1021         }
1022         if (execId != null) {
1023             registerExecution(id, execId);
1024             scheduleNextGetUpdates();
1025         }
1026     }
1027
1028     private boolean reLogin() {
1029         logger.debug("Doing relogin");
1030         reLoginNeeded = true;
1031         localToken = "";
1032         login();
1033         return ThingStatus.OFFLINE != thing.getStatus();
1034     }
1035
1036     public @Nullable String executeActionGroupInternal(String id) {
1037         SomfyTahomaApplyResponse response = invokeCallToURL(EXEC_URL + id, "", HttpMethod.POST,
1038                 SomfyTahomaApplyResponse.class);
1039         if (response != null) {
1040             if (response.getExecId().isEmpty()) {
1041                 logger.debug("Got empty exec response");
1042                 return null;
1043             }
1044             return response.getExecId();
1045         }
1046         return null;
1047     }
1048
1049     public void forceGatewaySync() {
1050         // refresh is valid only if in a cloud mode
1051         if (!thingConfig.isDevMode() || localToken.isEmpty()) {
1052             invokeCallToURL(REFRESH_URL, "", HttpMethod.PUT, null);
1053         }
1054     }
1055
1056     public SomfyTahomaStatus getTahomaStatus(String gatewayId) {
1057         SomfyTahomaStatusResponse status = null;
1058
1059         if (isDevModeReady()) {
1060             // Local endpoint does not have a method for specific gateway
1061             SomfyTahomaStatusResponse[] data = invokeCallToURL(GATEWAYS_URL, "", HttpMethod.GET,
1062                     SomfyTahomaStatusResponse[].class);
1063             if (data != null) {
1064                 for (SomfyTahomaStatusResponse gatewayStatus : data) {
1065                     if (gatewayStatus.getGatewayId().equals(gatewayId)) {
1066                         status = gatewayStatus;
1067                         break;
1068                     }
1069                 }
1070             }
1071         } else {
1072             status = invokeCallToURL(GATEWAYS_URL + gatewayId, "", HttpMethod.GET, SomfyTahomaStatusResponse.class);
1073         }
1074
1075         if (status != null) {
1076             logger.debug("Tahoma status: {}", status.getConnectivity().getStatus());
1077             logger.debug("Tahoma protocol version: {}", status.getConnectivity().getProtocolVersion());
1078             return status.getConnectivity();
1079         }
1080         return new SomfyTahomaStatus();
1081     }
1082
1083     private boolean isTempBanned(Exception ex) {
1084         String msg = ex.getMessage();
1085         return msg != null && msg.contains(TEMPORARILY_BANNED);
1086     }
1087
1088     private boolean isEventListenerTimeout(Exception ex) {
1089         String msg = ex.getMessage();
1090         return msg != null && msg.contains(EVENT_LISTENER_TIMEOUT);
1091     }
1092
1093     private boolean isOAuthGrantError(Exception ex) {
1094         String msg = ex.getMessage();
1095         return msg != null
1096                 && (msg.contains(AUTHENTICATION_OAUTH_GRANT_ERROR) || msg.contains(AUTHENTICATION_OAUTH_INVALID_GRANT));
1097     }
1098
1099     @Override
1100     public void handleConfigurationUpdate(Map<String, Object> configurationParameters) {
1101         super.handleConfigurationUpdate(configurationParameters);
1102         if (configurationParameters.containsKey("email") || configurationParameters.containsKey("password")
1103                 || configurationParameters.containsKey("portalUrl")) {
1104             reLoginNeeded = true;
1105             tooManyRequests = false;
1106         }
1107     }
1108
1109     public synchronized void refresh(String url, String stateName) {
1110         SomfyTahomaState state = invokeCallToURL(DEVICES_URL + urlEncode(url) + "/states/" + stateName, "",
1111                 HttpMethod.GET, SomfyTahomaState.class);
1112         if (state != null && !state.getName().isEmpty()) {
1113             updateDevice(url, List.of(state));
1114         }
1115     }
1116
1117     private @Nullable <T> T invokeCallToURL(String url, String urlParameters, HttpMethod method,
1118             @Nullable Class<T> classOfT) {
1119         String response = "";
1120         try {
1121             switch (method) {
1122                 case GET:
1123                     response = sendGetToTahomaWithCookie(url);
1124                     break;
1125                 case PUT:
1126                     response = sendPutToTahomaWithCookie(url);
1127                     break;
1128                 case POST:
1129                     response = sendPostToTahomaWithCookie(url, urlParameters);
1130                     break;
1131                 case DELETE:
1132                     response = sendDeleteToTahomaWithCookie(url);
1133                 default:
1134             }
1135             errorsCounter = 0;
1136             return classOfT != null ? gson.fromJson(response, classOfT) : null;
1137         } catch (JsonSyntaxException e) {
1138             logger.debug("Received data: {} is not JSON", response, e);
1139             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Received invalid data");
1140         } catch (ExecutionException e) {
1141             if (isTempBanned(e)) {
1142                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Temporarily banned");
1143                 setTooManyRequests();
1144             } else if (isEventListenerTimeout(e)) {
1145                 logger.debug("Event listener timeout occurred", e);
1146                 reLogin();
1147             } else if (isDevModeReady()) {
1148                 // the local gateway is unreachable
1149                 errorsCounter++;
1150                 logger.debug("Local gateway communication error", e);
1151                 discoverGateway();
1152                 if (errorsCounter > MAX_ERRORS) {
1153                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
1154                             "Too many communication errors");
1155                 }
1156             } else {
1157                 logger.debug("Cannot call url: {} with params: {}!", getApiFullUrl(url), urlParameters, e);
1158                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
1159             }
1160         } catch (TimeoutException e) {
1161             errorsCounter++;
1162             logger.debug("Timeout when calling url: {} with params: {}!", getApiFullUrl(url), urlParameters, e);
1163             if (errorsCounter > MAX_ERRORS) {
1164                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Too many timeouts");
1165             }
1166         } catch (InterruptedException e) {
1167             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
1168             Thread.currentThread().interrupt();
1169         }
1170         return null;
1171     }
1172
1173     public void setGatewayIPAddress(String gatewayIPAddress) {
1174         thingConfig.setIp(gatewayIPAddress);
1175     }
1176
1177     public void setGatewayPin(String gatewayPin) {
1178         thingConfig.setPin(gatewayPin);
1179     }
1180
1181     public void updateConfiguration() {
1182         Configuration config = editConfiguration();
1183         config.put("ip", thingConfig.getIp());
1184         config.put("pin", thingConfig.getPin());
1185         if (!localToken.isEmpty()) {
1186             config.put("token", localToken);
1187         }
1188         updateConfiguration(config);
1189     }
1190 }