2 * Copyright (c) 2010-2022 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.somfytahoma.internal.handler;
15 import static org.openhab.binding.somfytahoma.internal.SomfyTahomaBindingConstants.*;
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;
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;
31 import javax.ws.rs.core.MediaType;
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;
70 import com.google.gson.Gson;
71 import com.google.gson.JsonElement;
72 import com.google.gson.JsonSyntaxException;
75 * The {@link SomfyTahomaBridgeHandler} is responsible for handling commands, which are
76 * sent to one of the channels.
78 * @author Ondrej Pecta - Initial contribution
79 * @author Laurent Garnier - Other portals integration
82 public class SomfyTahomaBridgeHandler extends BaseBridgeHandler {
84 private final Logger logger = LoggerFactory.getLogger(SomfyTahomaBridgeHandler.class);
87 * The shared HttpClient
89 private final HttpClient httpClient;
92 * Future to poll for updates
94 private @Nullable ScheduledFuture<?> pollFuture;
97 * Future to poll for status
99 private @Nullable ScheduledFuture<?> statusFuture;
102 * Future to set reconciliation flag
104 private @Nullable ScheduledFuture<?> reconciliationFuture;
106 // List of futures used for command retries
107 private Collection<ScheduledFuture<?>> retryFutures = new ConcurrentLinkedQueue<ScheduledFuture<?>>();
112 private Map<String, String> executions = new HashMap<>();
114 // Too many requests flag
115 private boolean tooManyRequests = false;
117 // Silent relogin flag
118 private boolean reLoginNeeded = false;
120 // Reconciliation flag
121 private boolean reconciliation = false;
126 protected SomfyTahomaConfig thingConfig = new SomfyTahomaConfig();
129 * Id of registered events
131 private String eventsId = "";
133 private Map<String, SomfyTahomaDevice> devicePlaces = new HashMap<>();
135 private ExpiringCache<List<SomfyTahomaDevice>> cachedDevices = new ExpiringCache<>(Duration.ofSeconds(30),
139 private final Gson gson = new Gson();
141 public SomfyTahomaBridgeHandler(Bridge thing, HttpClientFactory httpClientFactory) {
143 this.httpClient = httpClientFactory.createHttpClient("somfy_" + thing.getUID().getId());
147 public void handleCommand(ChannelUID channelUID, Command command) {
151 public void initialize() {
152 thingConfig = getConfigAs(SomfyTahomaConfig.class);
156 } catch (Exception e) {
157 logger.debug("Cannot start http client for: {}", thing.getBridgeUID().getId(), e);
161 scheduler.execute(() -> {
164 logger.debug("Initialize done...");
169 * starts this things polling future
171 private void initPolling() {
173 scheduleGetUpdates(10);
175 statusFuture = scheduler.scheduleWithFixedDelay(() -> {
176 refreshTahomaStates();
177 }, 60, thingConfig.getStatusTimeout(), TimeUnit.SECONDS);
179 reconciliationFuture = scheduler.scheduleWithFixedDelay(() -> {
180 enableReconciliation();
181 }, RECONCILIATION_TIME, RECONCILIATION_TIME, TimeUnit.SECONDS);
184 private void scheduleGetUpdates(long delay) {
185 pollFuture = scheduler.schedule(() -> {
187 scheduleNextGetUpdates();
188 }, delay, TimeUnit.SECONDS);
191 private void scheduleNextGetUpdates() {
192 ScheduledFuture<?> localPollFuture = pollFuture;
193 if (localPollFuture != null) {
194 localPollFuture.cancel(false);
196 scheduleGetUpdates(executions.isEmpty() ? thingConfig.getRefresh() : 2);
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");
206 if (tooManyRequests) {
207 logger.debug("Skipping login due to too many requests");
211 if (ThingStatus.ONLINE == thing.getStatus() && !reLoginNeeded) {
212 logger.debug("No need to log in, because already logged in");
216 reLoginNeeded = false;
220 String urlParameters = "";
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();
227 urlParameters = "userId=" + urlEncode(thingConfig.getEmail()) + "&userPassword="
228 + urlEncode(thingConfig.getPassword());
231 ContentResponse response = sendRequestBuilder("login", HttpMethod.POST)
232 .content(new StringContentProvider(urlParameters),
233 "application/x-www-form-urlencoded; charset=UTF-8")
236 if (logger.isTraceEnabled()) {
237 logger.trace("Login response: {}", response.getContentAsString());
240 SomfyTahomaLoginResponse data = gson.fromJson(response.getContentAsString(),
241 SomfyTahomaLoginResponse.class);
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)) {
250 logger.debug("Events id: {}", eventsId);
251 updateStatus(ThingStatus.ONLINE);
253 logger.debug("Events id error: {}", id);
256 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
257 "Error logging in: " + data.getError());
258 if (data.getError().startsWith(TOO_MANY_REQUESTS)) {
259 setTooManyRequests();
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();
271 logger.debug("Cannot get login cookie", e);
272 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Cannot get login cookie");
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();
283 private void setTooManyRequests() {
284 logger.debug("Too many requests or bad credentials for the cloud portal, suspending activity for {} seconds",
286 tooManyRequests = true;
287 scheduler.schedule(this::enableLogin, SUSPEND_TIME, TimeUnit.SECONDS);
290 private @Nullable String registerEvents() {
291 SomfyTahomaRegisterEventsResponse response = invokeCallToURL(EVENTS_URL + "register", "", HttpMethod.POST,
292 SomfyTahomaRegisterEventsResponse.class);
293 return response != null ? response.getId() : null;
296 private String urlEncode(String text) {
297 return URLEncoder.encode(text, StandardCharsets.UTF_8);
300 private void enableLogin() {
301 tooManyRequests = false;
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();
311 public void handleRemoval() {
312 super.handleRemoval();
317 public Collection<Class<? extends ThingHandlerService>> getServices() {
318 return Collections.singleton(SomfyTahomaItemDiscoveryService.class);
322 public void dispose() {
327 private void cleanup() {
328 logger.debug("Doing cleanup");
331 // cancel all scheduled retries
332 retryFutures.forEach(x -> x.cancel(false));
336 } catch (Exception e) {
337 logger.debug("Error during http client stopping", e);
342 public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
343 super.bridgeStatusChanged(bridgeStatusInfo);
344 if (ThingStatus.UNINITIALIZED == bridgeStatusInfo.getStatus()) {
350 * Stops this thing's polling future
352 private void stopPolling() {
353 ScheduledFuture<?> localPollFuture = pollFuture;
354 if (localPollFuture != null && !localPollFuture.isCancelled()) {
355 localPollFuture.cancel(true);
357 ScheduledFuture<?> localStatusFuture = statusFuture;
358 if (localStatusFuture != null && !localStatusFuture.isCancelled()) {
359 localStatusFuture.cancel(true);
361 ScheduledFuture<?> localReconciliationFuture = reconciliationFuture;
362 if (localReconciliationFuture != null && !localReconciliationFuture.isCancelled()) {
363 localReconciliationFuture.cancel(true);
367 public List<SomfyTahomaActionGroup> listActionGroups() {
368 SomfyTahomaActionGroup[] list = invokeCallToURL("actionGroups", "", HttpMethod.GET,
369 SomfyTahomaActionGroup[].class);
370 return list != null ? List.of(list) : List.of();
373 public @Nullable SomfyTahomaSetup getSetup() {
374 SomfyTahomaSetup setup = invokeCallToURL("setup", "", HttpMethod.GET, SomfyTahomaSetup.class);
376 saveDevicePlaces(setup.getDevices());
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);
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())) {
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);
413 private void getTahomaUpdates() {
414 logger.debug("Getting Tahoma Updates...");
415 if (ThingStatus.OFFLINE == thing.getStatus() && !reLogin()) {
419 List<SomfyTahomaEvent> events = getEvents();
420 logger.trace("Got total of {} events", events.size());
421 for (SomfyTahomaEvent event : events) {
426 private void processEvent(SomfyTahomaEvent event) {
427 logger.debug("Got event: {}", event.getName());
428 switch (event.getName()) {
429 case "ExecutionRegisteredEvent":
430 processExecutionRegisteredEvent(event);
432 case "ExecutionStateChangedEvent":
433 processExecutionChangedEvent(event);
435 case "DeviceStateChangedEvent":
436 processStateChangedEvent(event);
438 case "RefreshAllDevicesStatesCompletedEvent":
439 scheduler.schedule(this::updateThings, 1, TimeUnit.SECONDS);
441 case "GatewayAliveEvent":
442 case "GatewayDownEvent":
443 processGatewayEvent(event);
446 // ignore other states
450 private synchronized void updateThings() {
451 boolean needsUpdate = reconciliation;
453 for (Thing th : getThing().getThings()) {
454 if (th.isEnabled() && ThingStatus.ONLINE != th.getStatus()) {
459 // update all states only if necessary
462 reconciliation = false;
466 private void processExecutionRegisteredEvent(SomfyTahomaEvent event) {
467 boolean invalidData = false;
469 JsonElement el = event.getAction();
470 if (el.isJsonArray()) {
471 SomfyTahomaAction[] actions = gson.fromJson(el, SomfyTahomaAction[].class);
472 if (actions == null) {
475 for (SomfyTahomaAction action : actions) {
476 registerExecution(action.getDeviceURL(), event.getExecId());
480 SomfyTahomaAction action = gson.fromJson(el, SomfyTahomaAction.class);
481 if (action == null) {
484 registerExecution(action.getDeviceURL(), event.getExecId());
487 } catch (JsonSyntaxException e) {
491 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
492 "Received invalid data (execution registered)");
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());
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);
508 executions.put(url, execId);
511 private void unregisterExecution(String execId) {
512 if (executions.containsValue(execId)) {
513 executions.values().removeAll(Collections.singleton(execId));
515 logger.debug("Cannot remove execution id: {}, because it is not registered", execId);
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);
531 private synchronized void updateAllStates() {
532 logger.debug("Updating all states");
533 getDevices().forEach(device -> updateDevice(device));
536 private void updateDevice(SomfyTahomaDevice device) {
537 String url = device.getDeviceURL();
538 List<SomfyTahomaState> states = device.getStates();
539 updateDevice(url, states);
542 private void updateDevice(String url, List<SomfyTahomaState> states) {
543 Thing th = getThingByDeviceUrl(url);
547 SomfyTahomaBaseThingHandler handler = (SomfyTahomaBaseThingHandler) th.getHandler();
548 if (handler != null) {
549 handler.updateThingStatus(states);
550 handler.updateThingChannels(states);
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);
561 logger.debug("Updating status of thing: {}", thing.getLabel());
562 SomfyTahomaBaseThingHandler handler = (SomfyTahomaBaseThingHandler) thing.getHandler();
564 if (handler != null) {
565 // update thing status
566 handler.updateThingStatus(states);
567 handler.updateThingChannels(states);
570 logger.debug("Thing is disabled or handler is null, probably not bound thing.");
574 private void enableReconciliation() {
575 logger.debug("Enabling reconciliation");
576 reconciliation = true;
579 private void refreshTahomaStates() {
580 logger.debug("Refreshing Tahoma states...");
581 if (ThingStatus.OFFLINE == thing.getStatus() && !reLogin()) {
585 // force Tahoma to ask for actual states
589 private @Nullable Thing getThingByDeviceUrl(String deviceUrl) {
590 for (Thing th : getThing().getThings()) {
591 if (!th.isEnabled()) {
594 String url = (String) th.getConfiguration().get("url");
595 if (deviceUrl.equals(url)) {
602 private void logout() {
605 sendGetToTahomaWithCookie("logout");
606 } catch (ExecutionException | TimeoutException e) {
607 logger.debug("Cannot send logout command!", e);
608 } catch (InterruptedException e) {
609 Thread.currentThread().interrupt();
613 private String sendPostToTahomaWithCookie(String url, String urlParameters)
614 throws InterruptedException, ExecutionException, TimeoutException {
615 return sendMethodToTahomaWithCookie(url, HttpMethod.POST, urlParameters);
618 private String sendGetToTahomaWithCookie(String url)
619 throws InterruptedException, ExecutionException, TimeoutException {
620 return sendMethodToTahomaWithCookie(url, HttpMethod.GET);
623 private String sendPutToTahomaWithCookie(String url)
624 throws InterruptedException, ExecutionException, TimeoutException {
625 return sendMethodToTahomaWithCookie(url, HttpMethod.PUT);
628 private String sendDeleteToTahomaWithCookie(String url)
629 throws InterruptedException, ExecutionException, TimeoutException {
630 return sendMethodToTahomaWithCookie(url, HttpMethod.DELETE);
633 private String sendMethodToTahomaWithCookie(String url, HttpMethod method)
634 throws InterruptedException, ExecutionException, TimeoutException {
635 return sendMethodToTahomaWithCookie(url, method, "");
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");
646 ContentResponse response = request.send();
648 if (logger.isTraceEnabled()) {
649 logger.trace("Response: {}", response.getContentAsString());
652 if (response.getStatus() < 200 || response.getStatus() >= 300) {
653 logger.debug("Received unexpected status code: {}", response.getStatus());
655 return response.getContentAsString();
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);
666 * Performs the login for Cozytouch using OAUTH2 authorization.
668 * @return JSESSION ID cookie value.
669 * @throws ExecutionException
670 * @throws TimeoutException
671 * @throws InterruptedException
672 * @throws JsonSyntaxException
674 private String loginCozytouch()
675 throws InterruptedException, TimeoutException, ExecutionException, JsonSyntaxException {
676 String authBaseUrl = "https://" + COZYTOUCH_OAUTH2_URL;
678 String urlParameters = "grant_type=password&username=" + urlEncode(thingConfig.getEmail()) + "&password="
679 + urlEncode(thingConfig.getPassword());
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")
689 if (response.getStatus() != 200) {
691 if (response.getHeaders().getField(HttpHeader.CONTENT_TYPE).getValue()
692 .equalsIgnoreCase(MediaType.APPLICATION_JSON)) {
694 SomfyTahomaOauth2Error error = gson.fromJson(response.getContentAsString(),
695 SomfyTahomaOauth2Error.class);
696 throw new ExecutionException(error.getErrorDescription(), null);
697 } catch (JsonSyntaxException e) {
701 throw new ExecutionException("Unknown error while attempting to log in.", null);
704 SomfyTahomaOauth2Reponse oauth2response = gson.fromJson(response.getContentAsString(),
705 SomfyTahomaOauth2Reponse.class);
707 logger.debug("OAuth2 Access Token: {}", oauth2response.getAccessToken());
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();
713 if (response.getStatus() == 200) {
714 String jwt = response.getContentAsString();
715 return jwt.replace("\"", "");
717 throw new ExecutionException(String.format("Failed to retrieve JWT token. ResponseCode=%d, ResponseText=%s",
718 response.getStatus(), response.getContentAsString()), null);
722 private String getApiFullUrl(String subUrl) {
723 return "https://" + thingConfig.getCloudPortal() + API_BASE_URL + subUrl;
726 public void sendCommand(String io, String command, String params, String url) {
727 if (ThingStatus.OFFLINE == thing.getStatus() && !reLogin()) {
731 removeFinishedRetries();
733 boolean result = sendCommandInternal(io, command, params, url);
735 scheduleRetry(io, command, params, url, thingConfig.getRetries());
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);
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();
760 logger.debug("ExecId is empty!");
768 private void removeFinishedRetries() {
769 retryFutures.removeIf(x -> x.isDone());
770 logger.debug("Currently {} retries are scheduled.", retryFutures.size());
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));
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);
789 sendCommand(io, command, params, url);
793 private String getThingLabelByURL(String io) {
794 Thing th = getThingByDeviceUrl(io);
796 if (th.getProperties().containsKey(NAME_STATE)) {
797 // Return label from Tahoma
798 return th.getProperties().get(NAME_STATE).replace("\"", "");
800 // Return label from the thing
801 String label = th.getLabel();
802 return label != null ? label.replace("\"", "") : "";
807 public @Nullable String getCurrentExecutions(String io) {
808 if (executions.containsKey(io)) {
809 return executions.get(io);
814 public void cancelExecution(String executionId) {
815 invokeCallToURL(DELETE_URL + executionId, "", HttpMethod.DELETE, null);
818 public void executeActionGroup(String id) {
819 if (ThingStatus.OFFLINE == thing.getStatus() && !reLogin()) {
822 String execId = executeActionGroupInternal(id);
823 if (execId == null) {
824 execId = executeActionGroupInternal(id);
826 if (execId != null) {
827 registerExecution(id, execId);
828 scheduleNextGetUpdates();
832 private boolean reLogin() {
833 logger.debug("Doing relogin");
834 reLoginNeeded = true;
836 return ThingStatus.OFFLINE != thing.getStatus();
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");
847 return response.getExecId();
852 public void forceGatewaySync() {
853 invokeCallToURL(REFRESH_URL, "", HttpMethod.PUT, null);
856 public SomfyTahomaStatus getTahomaStatus(String gatewayId) {
857 SomfyTahomaStatusResponse data = invokeCallToURL(GATEWAYS_URL + gatewayId, "", HttpMethod.GET,
858 SomfyTahomaStatusResponse.class);
860 logger.debug("Tahoma status: {}", data.getConnectivity().getStatus());
861 logger.debug("Tahoma protocol version: {}", data.getConnectivity().getProtocolVersion());
862 return data.getConnectivity();
864 return new SomfyTahomaStatus();
867 private boolean isAuthenticationChallenge(Exception ex) {
868 String msg = ex.getMessage();
869 return msg != null && msg.contains(AUTHENTICATION_CHALLENGE);
872 private boolean isOAuthGrantError(Exception ex) {
873 String msg = ex.getMessage();
874 return msg != null && msg.contains(AUTHENTICATION_OAUTH_GRANT_ERROR);
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;
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));
895 private @Nullable <T> T invokeCallToURL(String url, String urlParameters, HttpMethod method,
896 @Nullable Class<T> classOfT) {
897 String response = "";
901 response = sendGetToTahomaWithCookie(url);
904 response = sendPutToTahomaWithCookie(url);
907 response = sendPostToTahomaWithCookie(url, urlParameters);
910 response = sendDeleteToTahomaWithCookie(url);
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)) {
921 logger.debug("Cannot call url: {} with params: {}!", getApiFullUrl(url), urlParameters, e);
922 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
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();