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