2 * Copyright (c) 2010-2024 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.io.IOException;
18 import java.net.InetAddress;
19 import java.net.URLEncoder;
20 import java.nio.charset.StandardCharsets;
21 import java.time.Duration;
22 import java.time.Instant;
23 import java.util.Collection;
24 import java.util.HashMap;
25 import java.util.List;
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;
34 import javax.jmdns.JmDNS;
35 import javax.ws.rs.core.MediaType;
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;
82 import com.google.gson.Gson;
83 import com.google.gson.JsonElement;
84 import com.google.gson.JsonSyntaxException;
87 * The {@link SomfyTahomaBridgeHandler} is responsible for handling commands, which are
88 * sent to one of the channels.
90 * @author Ondrej Pecta - Initial contribution
91 * @author Laurent Garnier - Other portals integration
94 public class SomfyTahomaBridgeHandler extends BaseBridgeHandler {
96 private final Logger logger = LoggerFactory.getLogger(SomfyTahomaBridgeHandler.class);
99 * The shared HttpClient
101 private @Nullable HttpClient httpClient;
104 * Future to poll for updates
106 private @Nullable ScheduledFuture<?> pollFuture;
109 * Future to poll for status
111 private @Nullable ScheduledFuture<?> statusFuture;
114 * Future to set reconciliation flag
116 private @Nullable ScheduledFuture<?> reconciliationFuture;
119 * Future for postponed login
121 private @Nullable ScheduledFuture<?> loginFuture;
123 // List of futures used for command retries
124 private Collection<ScheduledFuture<?>> retryFutures = new ConcurrentLinkedQueue<>();
129 private Map<String, String> executions = new HashMap<>();
131 // Too many requests flag
132 private boolean tooManyRequests = false;
134 // Silent relogin flag
135 private boolean reLoginNeeded = false;
137 // Reconciliation flag
138 private boolean reconciliation = false;
141 private boolean cloudFallback = false;
143 // Communication errors counter
144 private int errorsCounter = 0;
146 // Last login timestamp
147 private Instant lastLoginTimestamp = Instant.MIN;
152 protected SomfyTahomaConfig thingConfig = new SomfyTahomaConfig();
155 * Id of registered events
157 private String eventsId = "";
159 private String localToken = "";
161 private String accessToken = "";
163 private Map<String, SomfyTahomaDevice> devicePlaces = new HashMap<>();
165 private ExpiringCache<List<SomfyTahomaDevice>> cachedDevices = new ExpiringCache<>(Duration.ofSeconds(30),
169 private final Gson gson = new Gson();
171 private final HttpClientFactory httpClientFactory;
173 public SomfyTahomaBridgeHandler(Bridge thing, HttpClientFactory httpClientFactory) {
175 this.httpClientFactory = httpClientFactory;
179 public void handleCommand(ChannelUID channelUID, Command command) {
183 public void initialize() {
184 updateStatus(ThingStatus.UNKNOWN);
185 thingConfig = getConfigAs(SomfyTahomaConfig.class);
188 scheduler.execute(() -> {
191 logger.debug("Initialize done...");
195 private void createHttpClient() {
196 // let's create the right http client
197 String clientName = ThingWebClientUtil.buildWebClientConsumerName(thing.getUID(), null);
198 if (thingConfig.isDevMode()) {
199 this.httpClient = httpClientFactory.createHttpClient(clientName, new SslContextFactory.Client(true));
201 this.httpClient = httpClientFactory.createHttpClient(clientName);
206 } catch (Exception e) {
207 logger.debug("Cannot start http client for: {}", thing.getUID(), e);
210 // Remove the WWWAuth protocol handler since Tahoma is not fully compliant
211 httpClient.getProtocolHandlers().remove(WWWAuthenticationProtocolHandler.NAME);
215 * starts this things polling future
217 private void initPolling() {
219 scheduleGetUpdates(10);
221 statusFuture = scheduler.scheduleWithFixedDelay(() -> {
222 refreshTahomaStates();
223 }, 60, thingConfig.getStatusTimeout(), TimeUnit.SECONDS);
225 reconciliationFuture = scheduler.scheduleWithFixedDelay(() -> {
226 enableReconciliation();
227 }, RECONCILIATION_TIME, RECONCILIATION_TIME, TimeUnit.SECONDS);
230 private void scheduleGetUpdates(long delay) {
231 pollFuture = scheduler.schedule(() -> {
233 scheduleNextGetUpdates();
234 }, delay, TimeUnit.SECONDS);
237 private void scheduleNextGetUpdates() {
238 ScheduledFuture<?> localPollFuture = pollFuture;
239 if (localPollFuture != null) {
240 localPollFuture.cancel(false);
242 scheduleGetUpdates(executions.isEmpty() ? thingConfig.getRefresh() : 2);
245 public synchronized void login() {
246 if (thingConfig.getEmail().isEmpty() || thingConfig.getPassword().isEmpty()) {
247 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
248 "Can not access device as username and/or password are null");
252 if (tooManyRequests || Instant.now().minusSeconds(LOGIN_LIMIT_TIME).isBefore(lastLoginTimestamp)) {
253 logger.debug("Postponing login to avoid throttling");
257 if (ThingStatus.ONLINE == thing.getStatus() && !reLoginNeeded) {
258 logger.debug("No need to log in, because already logged in");
262 reLoginNeeded = false;
263 cloudFallback = false;
266 lastLoginTimestamp = Instant.now();
268 if (thingConfig.getCloudPortal().equalsIgnoreCase(COZYTOUCH_PORTAL)) {
269 if (!loginCozyTouch()) {
276 if (thingConfig.isDevMode()) {
277 initializeLocalMode();
280 String id = registerEvents();
281 if (id != null && !UNAUTHORIZED.equals(id)) {
283 logger.debug("Events id: {}", eventsId);
284 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE,
285 isDevModeReady() ? "LAN mode" : cloudFallback ? "Cloud mode fallback" : "Cloud mode");
287 logger.debug("Events id error: {}", id);
288 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "unable to register events");
290 } catch (JsonSyntaxException e) {
291 logger.debug("Received invalid data (login)", e);
292 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Received invalid data (login)");
293 } catch (ExecutionException e) {
294 if (isOAuthGrantError(e)) {
295 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
296 "Error logging in (check your credentials)");
297 setTooManyRequests();
299 logger.debug("Cannot get login cookie", e);
300 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Cannot get login cookie");
302 } catch (TimeoutException e) {
303 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Getting login cookie timeout");
304 } catch (InterruptedException e) {
305 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
306 "Getting login cookie interrupted");
307 Thread.currentThread().interrupt();
311 private boolean loginCozyTouch()
312 throws ExecutionException, InterruptedException, TimeoutException, JsonSyntaxException {
313 logger.debug("CozyTouch Oauth2 authentication flow");
314 String urlParameters = "jwt=" + getCozytouchJWT();
315 ContentResponse response = sendRequestBuilder("login", HttpMethod.POST)
316 .content(new StringContentProvider(urlParameters), "application/x-www-form-urlencoded; charset=UTF-8")
319 if (logger.isTraceEnabled()) {
320 logger.trace("Login response: {}", response.getContentAsString());
323 SomfyTahomaLoginResponse data = gson.fromJson(response.getContentAsString(), SomfyTahomaLoginResponse.class);
326 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Received invalid data (login)");
328 } else if (!data.getErrorCode().isEmpty()) {
329 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, data.getError());
330 if (data.getError().startsWith(TOO_MANY_REQUESTS)) {
331 setTooManyRequests();
338 public boolean isDevModeReady() {
339 return thingConfig.isDevMode() && !localToken.isEmpty() && !cloudFallback;
342 private void initializeLocalMode() {
343 if (thingConfig.getIp().isEmpty() || thingConfig.getPin().isEmpty()) {
347 if (!thingConfig.getIp().isEmpty() && !thingConfig.getPin().isEmpty()) {
349 if (thingConfig.getToken().isEmpty()) {
350 localToken = getNewLocalToken();
351 logger.debug("Local token retrieved");
352 activateLocalToken();
353 updateConfiguration();
355 localToken = thingConfig.getToken();
356 activateLocalToken();
358 logger.debug("Local mode initialized, waiting for cloud sync");
360 } catch (InterruptedException ex) {
361 logger.debug("Interruption during local mode initialization, falling back to cloud mode", ex);
362 Thread.currentThread().interrupt();
363 } catch (ExecutionException | TimeoutException ex) {
364 logger.debug("Exception during local mode initialization, falling back to cloud mode", ex);
365 cloudFallback = true;
368 logger.debug("Cannot switch to developer mode - gateway not found on LAN");
369 cloudFallback = true;
373 private String getNewLocalToken() throws ExecutionException, InterruptedException, TimeoutException {
374 // Get list of local tokens
375 SomfyTahomaLocalToken[] tokens = invokeCallToURL(
376 CONFIG_URL + thingConfig.getPin() + LOCAL_TOKENS_URL + "devmode", "", HttpMethod.GET,
377 SomfyTahomaLocalToken[].class);
379 // Delete old OH tokens
380 for (SomfyTahomaLocalToken token : tokens) {
381 if (OPENHAB_TOKEN.equals(token.getLabel())) {
382 logger.debug("Deleting token: {}", token.getUuid());
383 sendDeleteToTahomaWithCookie(CONFIG_URL + thingConfig.getPin() + LOCAL_TOKENS_URL + token.getUuid());
387 // Generate a new token
388 SomfyTahomaTokenReponse tokenResponse = invokeCallToURL(
389 CONFIG_URL + thingConfig.getPin() + LOCAL_TOKENS_URL + "generate", "", HttpMethod.GET,
390 SomfyTahomaTokenReponse.class);
392 return tokenResponse.getToken();
395 private void discoverGateway() {
396 logger.debug("Starting mDNS discovery...");
400 // Create a JmDNS instance
401 jmdns = JmDNS.create(InetAddress.getLocalHost());
402 jmdns.addServiceListener("_kizboxdev._tcp.local.", new SomfyTahomaMDNSDiscoveryListener(this));
405 Thread.sleep(TAHOMA_TIMEOUT * 1000);
406 } catch (InterruptedException e) {
407 logger.debug("mDNS discovery interrupted", e);
408 Thread.currentThread().interrupt();
409 } catch (IOException e) {
410 logger.debug("Exception during mDNS discovery", e);
414 jmdns.unregisterAllServices();
417 } catch (IOException e) {
423 private void activateLocalToken() throws ExecutionException, InterruptedException, TimeoutException {
424 String param = "{\"label\" : \"" + OPENHAB_TOKEN + "\",\"token\" : \"" + localToken
425 + "\",\"scope\" : \"devmode\"}";
426 String response = sendPostToTahomaWithCookie(CONFIG_URL + thingConfig.getPin() + "/local/tokens", param);
427 logger.trace("Local token activation: {}", response);
430 private void setTooManyRequests() {
431 if (!tooManyRequests) {
433 "Too many requests or bad credentials for the cloud portal, suspending activity for {} seconds",
435 tooManyRequests = true;
436 loginFuture = scheduler.schedule(this::enableLogin, SUSPEND_TIME, TimeUnit.SECONDS);
440 private @Nullable String registerEvents() {
441 SomfyTahomaRegisterEventsResponse response = invokeCallToURL(EVENTS_URL + "register", "", HttpMethod.POST,
442 SomfyTahomaRegisterEventsResponse.class);
443 return response != null ? response.getId() : null;
446 private String urlEncode(String text) {
447 return URLEncoder.encode(text, StandardCharsets.UTF_8);
450 private void enableLogin() {
451 tooManyRequests = false;
454 private List<SomfyTahomaEvent> getEvents() {
455 if (eventsId.isEmpty()) {
459 SomfyTahomaEvent[] response = invokeCallToURL(EVENTS_URL + eventsId + "/fetch", "", HttpMethod.POST,
460 SomfyTahomaEvent[].class);
461 return response != null ? List.of(response) : List.of();
465 public void handleRemoval() {
466 super.handleRemoval();
471 public Collection<Class<? extends ThingHandlerService>> getServices() {
472 return Set.of(SomfyTahomaItemDiscoveryService.class);
476 public void dispose() {
481 private void cleanup() {
482 logger.debug("Doing cleanup");
485 // cancel all scheduled retries
486 retryFutures.forEach(x -> x.cancel(false));
488 ScheduledFuture<?> localLoginFuture = loginFuture;
489 if (localLoginFuture != null) {
490 localLoginFuture.cancel(true);
494 HttpClient localHttpClient = httpClient;
495 if (localHttpClient != null) {
497 localHttpClient.stop();
498 } catch (Exception e) {
499 logger.debug("Error during http client stopping", e);
510 public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
511 super.bridgeStatusChanged(bridgeStatusInfo);
512 if (ThingStatus.UNINITIALIZED == bridgeStatusInfo.getStatus()) {
518 * Stops this thing's polling future
520 private void stopPolling() {
521 ScheduledFuture<?> localPollFuture = pollFuture;
522 if (localPollFuture != null) {
523 localPollFuture.cancel(true);
526 ScheduledFuture<?> localStatusFuture = statusFuture;
527 if (localStatusFuture != null) {
528 localStatusFuture.cancel(true);
531 ScheduledFuture<?> localReconciliationFuture = reconciliationFuture;
532 if (localReconciliationFuture != null) {
533 localReconciliationFuture.cancel(true);
534 reconciliationFuture = null;
538 public List<SomfyTahomaActionGroup> listActionGroups() {
539 SomfyTahomaActionGroup[] list = invokeCallToURL("actionGroups", "", HttpMethod.GET,
540 SomfyTahomaActionGroup[].class);
541 return list != null ? List.of(list) : List.of();
544 public @Nullable SomfyTahomaSetup getSetup() {
545 SomfyTahomaSetup setup = invokeCallToURL("setup", "", HttpMethod.GET, SomfyTahomaSetup.class);
547 saveDevicePlaces(setup.getDevices());
552 public List<SomfyTahomaDevice> getDevices() {
553 SomfyTahomaDevice[] response = invokeCallToURL(SETUP_URL + "devices", "", HttpMethod.GET,
554 SomfyTahomaDevice[].class);
555 List<SomfyTahomaDevice> devices = response != null ? List.of(response) : List.of();
556 saveDevicePlaces(devices);
560 public synchronized @Nullable SomfyTahomaDevice getCachedDevice(String url) {
561 List<SomfyTahomaDevice> devices = cachedDevices.getValue();
562 if (devices != null) {
563 for (SomfyTahomaDevice device : devices) {
564 if (url.equals(device.getDeviceURL())) {
572 private void saveDevicePlaces(List<SomfyTahomaDevice> devices) {
573 devicePlaces.clear();
574 for (SomfyTahomaDevice device : devices) {
575 if (!device.getPlaceOID().isEmpty()) {
576 SomfyTahomaDevice newDevice = new SomfyTahomaDevice();
577 newDevice.setPlaceOID(device.getPlaceOID());
578 newDevice.getDefinition().setWidgetName(device.getDefinition().getWidgetName());
579 devicePlaces.put(device.getDeviceURL(), newDevice);
584 private void getTahomaUpdates() {
585 logger.debug("Getting Tahoma Updates...");
586 if (ThingStatus.OFFLINE == thing.getStatus() && !reLogin()) {
590 List<SomfyTahomaEvent> events = getEvents();
591 logger.trace("Got total of {} events", events.size());
592 for (SomfyTahomaEvent event : events) {
597 private void processEvent(SomfyTahomaEvent event) {
598 logger.debug("Got event: {}", event.getName());
599 switch (event.getName()) {
600 case "ExecutionRegisteredEvent":
601 processExecutionRegisteredEvent(event);
603 case "ExecutionStateChangedEvent":
604 processExecutionChangedEvent(event);
606 case "DeviceStateChangedEvent":
607 processStateChangedEvent(event);
609 case "RefreshAllDevicesStatesCompletedEvent":
610 scheduler.schedule(this::updateThings, 1, TimeUnit.SECONDS);
612 case "GatewayAliveEvent":
613 case "GatewayDownEvent":
614 processGatewayEvent(event);
617 // ignore other states
621 private synchronized void updateThings() {
622 boolean needsUpdate = reconciliation;
624 for (Thing th : getThing().getThings()) {
625 if (th.isEnabled() && ThingStatus.ONLINE != th.getStatus()) {
630 // update all states only if necessary
633 reconciliation = false;
637 private void processExecutionRegisteredEvent(SomfyTahomaEvent event) {
638 boolean invalidData = false;
640 JsonElement el = event.getAction();
641 if (el.isJsonArray()) {
642 SomfyTahomaAction[] actions = gson.fromJson(el, SomfyTahomaAction[].class);
643 if (actions == null) {
646 for (SomfyTahomaAction action : actions) {
647 registerExecution(action.getDeviceURL(), event.getExecId());
651 SomfyTahomaAction action = gson.fromJson(el, SomfyTahomaAction.class);
652 if (action == null) {
655 registerExecution(action.getDeviceURL(), event.getExecId());
658 } catch (JsonSyntaxException e) {
662 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
663 "Received invalid data (execution registered)");
667 private void processExecutionChangedEvent(SomfyTahomaEvent event) {
668 if (FAILED_EVENT.equals(event.getNewState()) || COMPLETED_EVENT.equals(event.getNewState())) {
669 logger.debug("Removing execution id: {}", event.getExecId());
670 unregisterExecution(event.getExecId());
674 private void registerExecution(String url, String execId) {
675 if (executions.containsKey(url)) {
676 executions.remove(url);
677 logger.debug("Previous execution exists for url: {}", url);
679 executions.put(url, execId);
682 private void unregisterExecution(String execId) {
683 if (executions.containsValue(execId)) {
684 executions.values().removeAll(Set.of(execId));
686 logger.debug("Cannot remove execution id: {}, because it is not registered", execId);
690 private void processGatewayEvent(SomfyTahomaEvent event) {
691 // update gateway status
692 for (Thing th : getThing().getThings()) {
693 if (th.isEnabled() && THING_TYPE_GATEWAY.equals(th.getThingTypeUID())) {
694 SomfyTahomaGatewayHandler gatewayHandler = (SomfyTahomaGatewayHandler) th.getHandler();
695 if (gatewayHandler != null && gatewayHandler.getGateWayId().equals(event.getGatewayId())) {
696 gatewayHandler.refresh(STATUS);
702 private synchronized void updateAllStates() {
703 logger.debug("Updating all states");
704 getDevices().forEach(device -> updateDevice(device));
707 private void updateDevice(SomfyTahomaDevice device) {
708 String url = device.getDeviceURL();
709 List<SomfyTahomaState> states = device.getStates();
710 updateDevice(url, states);
713 private void updateDevice(String url, List<SomfyTahomaState> states) {
714 Thing th = getThingByDeviceUrl(url);
718 SomfyTahomaBaseThingHandler handler = (SomfyTahomaBaseThingHandler) th.getHandler();
719 if (handler != null) {
720 handler.updateThingStatus(states);
721 handler.updateThingChannels(states);
725 private void processStateChangedEvent(SomfyTahomaEvent event) {
726 String deviceUrl = event.getDeviceUrl();
727 List<SomfyTahomaState> states = event.getDeviceStates();
728 logger.debug("States for device {} : {}", deviceUrl, states);
729 Thing thing = getThingByDeviceUrl(deviceUrl);
732 logger.debug("Updating status of thing: {}", thing.getLabel());
733 SomfyTahomaBaseThingHandler handler = (SomfyTahomaBaseThingHandler) thing.getHandler();
735 if (handler != null) {
736 // update thing status
737 handler.updateThingStatus(states);
738 handler.updateThingChannels(states);
741 logger.debug("Thing is disabled or handler is null, probably not bound thing.");
745 private void enableReconciliation() {
746 logger.debug("Enabling reconciliation");
747 reconciliation = true;
750 private void refreshTahomaStates() {
751 logger.debug("Refreshing Tahoma states...");
752 if (ThingStatus.OFFLINE == thing.getStatus() && !reLogin()) {
756 // force Tahoma to ask for actual states
760 private @Nullable Thing getThingByDeviceUrl(String deviceUrl) {
761 for (Thing th : getThing().getThings()) {
762 if (!th.isEnabled()) {
765 String url = (String) th.getConfiguration().get("url");
766 if (deviceUrl.equals(url)) {
773 private void logout() {
776 sendGetToTahomaWithCookie("logout");
777 } catch (ExecutionException | TimeoutException e) {
778 logger.debug("Cannot send logout command!", e);
779 } catch (InterruptedException e) {
780 Thread.currentThread().interrupt();
784 private String sendPostToTahomaWithCookie(String url, String urlParameters)
785 throws InterruptedException, ExecutionException, TimeoutException {
786 return sendMethodToTahomaWithCookie(url, HttpMethod.POST, urlParameters);
789 private String sendGetToTahomaWithCookie(String url)
790 throws InterruptedException, ExecutionException, TimeoutException {
791 return sendMethodToTahomaWithCookie(url, HttpMethod.GET);
794 private String sendPutToTahomaWithCookie(String url)
795 throws InterruptedException, ExecutionException, TimeoutException {
796 return sendMethodToTahomaWithCookie(url, HttpMethod.PUT);
799 private String sendDeleteToTahomaWithCookie(String url)
800 throws InterruptedException, ExecutionException, TimeoutException {
801 return sendMethodToTahomaWithCookie(url, HttpMethod.DELETE);
804 private String sendMethodToTahomaWithCookie(String url, HttpMethod method)
805 throws InterruptedException, ExecutionException, TimeoutException {
806 return sendMethodToTahomaWithCookie(url, method, "");
809 private String sendMethodToTahomaWithCookie(String url, HttpMethod method, String urlParameters)
810 throws InterruptedException, ExecutionException, TimeoutException {
811 logger.debug("Sending {} to url: {} with data: {}", method.asString(), getApiFullUrl(url), urlParameters);
812 Request request = sendRequestBuilder(url, method);
813 if (!urlParameters.isEmpty()) {
814 request = request.content(new StringContentProvider(urlParameters), "application/json");
817 ContentResponse response = request.send();
819 if (logger.isTraceEnabled()) {
820 logger.trace("Response: {}", response.getContentAsString());
823 if (response.getStatus() < 200 || response.getStatus() >= 300) {
824 logger.debug("Received unexpected status code: {}", response.getStatus());
825 if (response.getHeaders().contains(HttpHeader.CONTENT_TYPE)) {
826 if (response.getHeaders().getField(HttpHeader.CONTENT_TYPE).getValue()
827 .equalsIgnoreCase(MediaType.APPLICATION_JSON)) {
829 SomfyTahomaError error = gson.fromJson(response.getContentAsString(), SomfyTahomaError.class);
830 throw new ExecutionException(error.getError(), null);
831 } catch (JsonSyntaxException e) {
836 throw new ExecutionException(
837 "Unknown http error " + response.getStatus() + " while attempting to send a message.", null);
839 return response.getContentAsString();
842 private Request sendRequestBuilder(String subUrl, HttpMethod method) {
843 return isLocalRequest(subUrl) ? sendRequestBuilderLocal(subUrl, method)
844 : sendRequestBuilderCloud(subUrl, method);
847 private boolean isLocalRequest(String subUrl) {
848 return isDevModeReady() && !subUrl.startsWith(CONFIG_URL);
851 private Request sendRequestBuilderCloud(String subUrl, HttpMethod method) {
852 Request request = httpClient.newRequest(getApiFullUrl(subUrl)).method(method)
853 .header(HttpHeader.ACCEPT_LANGUAGE, "en-US,en").header(HttpHeader.ACCEPT_ENCODING, "gzip, deflate")
854 .header("X-Requested-With", "XMLHttpRequest").timeout(TAHOMA_TIMEOUT, TimeUnit.SECONDS)
855 .agent(TAHOMA_AGENT);
857 if (!thingConfig.getCloudPortal().equalsIgnoreCase(COZYTOUCH_PORTAL)) {
858 // user OAuth token if not cozytouch
859 request = request.header(HttpHeader.AUTHORIZATION, "Bearer " + accessToken);
864 private Request sendRequestBuilderLocal(String subUrl, HttpMethod method) {
865 return httpClient.newRequest(getApiFullUrl(subUrl)).method(method).accept("application/json")
866 .timeout(TAHOMA_TIMEOUT, TimeUnit.SECONDS).header(HttpHeader.AUTHORIZATION, "Bearer " + localToken);
870 * Performs the login for Cozytouch using OAUTH2 authorization.
872 * @return JSESSION ID cookie value.
873 * @throws ExecutionException
874 * @throws TimeoutException
875 * @throws InterruptedException
876 * @throws JsonSyntaxException
878 private String getCozytouchJWT()
879 throws InterruptedException, TimeoutException, ExecutionException, JsonSyntaxException {
880 String authBaseUrl = "https://" + COZYTOUCH_OAUTH2_URL;
882 String urlParameters = "grant_type=password&username=" + urlEncode(thingConfig.getEmail()) + "&password="
883 + urlEncode(thingConfig.getPassword());
885 ContentResponse response = httpClient.newRequest(authBaseUrl + COZYTOUCH_OAUTH2_TOKEN_URL)
886 .method(HttpMethod.POST).header(HttpHeader.ACCEPT_LANGUAGE, "en-US,en")
887 .header(HttpHeader.ACCEPT_ENCODING, "gzip, deflate").header("X-Requested-With", "XMLHttpRequest")
888 .header(HttpHeader.AUTHORIZATION, "Basic " + COZYTOUCH_OAUTH2_BASICAUTH)
889 .timeout(TAHOMA_TIMEOUT, TimeUnit.SECONDS).agent(TAHOMA_AGENT)
890 .content(new StringContentProvider(urlParameters), "application/x-www-form-urlencoded; charset=UTF-8")
893 if (response.getStatus() != 200) {
895 if (response.getHeaders().getField(HttpHeader.CONTENT_TYPE).getValue()
896 .equalsIgnoreCase(MediaType.APPLICATION_JSON)) {
898 SomfyTahomaOauth2Error error = gson.fromJson(response.getContentAsString(),
899 SomfyTahomaOauth2Error.class);
900 throw new ExecutionException(error.getErrorDescription(), null);
901 } catch (JsonSyntaxException e) {
905 throw new ExecutionException("Unknown error while attempting to log in.", null);
908 SomfyTahomaOauth2Reponse oauth2response = gson.fromJson(response.getContentAsString(),
909 SomfyTahomaOauth2Reponse.class);
911 logger.debug("OAuth2 Access Token: {}", oauth2response.getAccessToken());
913 response = httpClient.newRequest(authBaseUrl + COZYTOUCH_OAUTH2_JWT_URL).method(HttpMethod.GET)
914 .header(HttpHeader.AUTHORIZATION, "Bearer " + oauth2response.getAccessToken())
915 .timeout(TAHOMA_TIMEOUT, TimeUnit.SECONDS).send();
917 if (response.getStatus() == 200) {
918 String jwt = response.getContentAsString();
919 return jwt.replace("\"", "");
921 throw new ExecutionException(String.format("Failed to retrieve JWT token. ResponseCode=%d, ResponseText=%s",
922 response.getStatus(), response.getContentAsString()), null);
926 private void loginOAUTH() throws InterruptedException, TimeoutException, ExecutionException, JsonSyntaxException {
927 String authBaseUrl = "https://" + SOMFY_OAUTH2_URL;
929 String urlParameters = "client_id=" + SOMFY_OAUTH2_CLIENT_ID + "&client_secret=" + SOMFY_OAUTH2_CLIENT_SECRET
930 + "&grant_type=password&username=" + urlEncode(thingConfig.getEmail()) + "&password="
931 + urlEncode(thingConfig.getPassword());
933 ContentResponse response = httpClient.newRequest(authBaseUrl).method(HttpMethod.POST)
934 .header(HttpHeader.ACCEPT_LANGUAGE, "en-US,en").header(HttpHeader.ACCEPT_ENCODING, "gzip, deflate")
935 .header("X-Requested-With", "XMLHttpRequest").timeout(TAHOMA_TIMEOUT, TimeUnit.SECONDS)
937 .content(new StringContentProvider(urlParameters), "application/x-www-form-urlencoded; charset=UTF-8")
940 if (response.getStatus() != 200) {
942 if (response.getHeaders().getField(HttpHeader.CONTENT_TYPE).getValue()
943 .equalsIgnoreCase(MediaType.APPLICATION_JSON)) {
945 SomfyTahomaOauth2Error error = gson.fromJson(response.getContentAsString(),
946 SomfyTahomaOauth2Error.class);
947 throw new ExecutionException(error.getErrorDescription(), null);
948 } catch (JsonSyntaxException e) {
951 throw new ExecutionException("Unknown error while attempting to log in.", null);
954 SomfyTahomaOauth2Reponse oauth2response = gson.fromJson(response.getContentAsString(),
955 SomfyTahomaOauth2Reponse.class);
957 logger.debug("OAuth2 Access Token: {}", oauth2response.getAccessToken());
959 accessToken = oauth2response.getAccessToken();
962 private String getApiFullUrl(String subUrl) {
963 return isLocalRequest(subUrl)
964 ? "https://" + thingConfig.getIp() + ":8443/enduser-mobile-web/1/enduserAPI/" + subUrl
965 : "https://" + thingConfig.getCloudPortal() + API_BASE_URL + subUrl;
968 public void sendCommand(String io, String command, String params, String url) {
969 if (ThingStatus.OFFLINE == thing.getStatus() && !reLogin()) {
973 removeFinishedRetries();
975 boolean result = sendCommandInternal(io, command, params, url);
977 scheduleRetry(io, command, params, url, thingConfig.getRetries());
981 private void repeatSendCommandInternal(String io, String command, String params, String url, int retries) {
982 logger.debug("Retrying command, retries left: {}", retries);
983 boolean result = sendCommandInternal(io, command, params, url);
984 if (!result && (retries > 0)) {
985 scheduleRetry(io, command, params, url, retries - 1);
989 private boolean sendCommandInternal(String io, String command, String params, String url) {
990 String value = "[]".equals(params) ? command : command + " " + params.replace("\"", "");
991 String urlParameters = "{\"label\":\"" + getThingLabelByURL(io) + " - " + value
992 + " - openHAB\",\"actions\":[{\"deviceURL\":\"" + io + "\",\"commands\":[{\"name\":\"" + command
993 + "\",\"parameters\":" + params + "}]}]}";
994 SomfyTahomaApplyResponse response = invokeCallToURL(url, urlParameters, HttpMethod.POST,
995 SomfyTahomaApplyResponse.class);
996 if (response != null) {
997 if (!response.getExecId().isEmpty()) {
998 logger.debug("Exec id: {}", response.getExecId());
999 registerExecution(io, response.getExecId());
1000 scheduleNextGetUpdates();
1002 logger.debug("ExecId is empty!");
1010 private void removeFinishedRetries() {
1011 retryFutures.removeIf(x -> x.isDone());
1012 logger.debug("Currently {} retries are scheduled.", retryFutures.size());
1015 private void scheduleRetry(String io, String command, String params, String url, int retries) {
1016 retryFutures.add(scheduler.schedule(() -> {
1017 repeatSendCommandInternal(io, command, params, url, retries);
1018 }, thingConfig.getRetryDelay(), TimeUnit.MILLISECONDS));
1021 public void sendCommandToSameDevicesInPlace(String io, String command, String params, String url) {
1022 SomfyTahomaDevice device = devicePlaces.get(io);
1023 if (device != null && !device.getPlaceOID().isEmpty()) {
1024 devicePlaces.forEach((deviceUrl, devicePlace) -> {
1025 if (device.getPlaceOID().equals(devicePlace.getPlaceOID())
1026 && device.getDefinition().getWidgetName().equals(devicePlace.getDefinition().getWidgetName())) {
1027 sendCommand(deviceUrl, command, params, url);
1031 sendCommand(io, command, params, url);
1035 private String getThingLabelByURL(String io) {
1036 Thing th = getThingByDeviceUrl(io);
1038 if (th.getProperties().containsKey(NAME_STATE)) {
1039 // Return label from Tahoma
1040 return th.getProperties().get(NAME_STATE).replace("\"", "");
1042 // Return label from the thing
1043 String label = th.getLabel();
1044 return label != null ? label.replace("\"", "") : "";
1049 public @Nullable String getCurrentExecutions(String io) {
1050 if (executions.containsKey(io)) {
1051 return executions.get(io);
1056 public void cancelExecution(String executionId) {
1057 invokeCallToURL(DELETE_URL + executionId, "", HttpMethod.DELETE, null);
1060 public void executeActionGroup(String id) {
1061 if (ThingStatus.OFFLINE == thing.getStatus() && !reLogin()) {
1064 String execId = executeActionGroupInternal(id);
1065 if (execId == null) {
1066 execId = executeActionGroupInternal(id);
1068 if (execId != null) {
1069 registerExecution(id, execId);
1070 scheduleNextGetUpdates();
1074 private boolean reLogin() {
1075 logger.debug("Doing relogin");
1076 reLoginNeeded = true;
1080 return ThingStatus.OFFLINE != thing.getStatus();
1083 public @Nullable String executeActionGroupInternal(String id) {
1084 SomfyTahomaApplyResponse response = invokeCallToURL(EXEC_URL + id, "", HttpMethod.POST,
1085 SomfyTahomaApplyResponse.class);
1086 if (response != null) {
1087 if (response.getExecId().isEmpty()) {
1088 logger.debug("Got empty exec response");
1091 return response.getExecId();
1096 public void forceGatewaySync() {
1097 // refresh is valid only if in a cloud mode
1098 if (!thingConfig.isDevMode() || localToken.isEmpty()) {
1099 invokeCallToURL(REFRESH_URL, "", HttpMethod.PUT, null);
1103 public SomfyTahomaStatus getTahomaStatus(String gatewayId) {
1104 SomfyTahomaStatusResponse status = null;
1106 if (isDevModeReady()) {
1107 // Local endpoint does not have a method for specific gateway
1108 SomfyTahomaStatusResponse[] data = invokeCallToURL(GATEWAYS_URL, "", HttpMethod.GET,
1109 SomfyTahomaStatusResponse[].class);
1111 for (SomfyTahomaStatusResponse gatewayStatus : data) {
1112 if (gatewayStatus.getGatewayId().equals(gatewayId)) {
1113 status = gatewayStatus;
1119 status = invokeCallToURL(GATEWAYS_URL + gatewayId, "", HttpMethod.GET, SomfyTahomaStatusResponse.class);
1122 if (status != null) {
1123 logger.debug("Tahoma status: {}", status.getConnectivity().getStatus());
1124 logger.debug("Tahoma protocol version: {}", status.getConnectivity().getProtocolVersion());
1125 return status.getConnectivity();
1127 return new SomfyTahomaStatus();
1130 private boolean isTempBanned(Exception ex) {
1131 String msg = ex.getMessage();
1132 return msg != null && msg.contains(TEMPORARILY_BANNED);
1135 private boolean isEventListenerTimeout(Exception ex) {
1136 String msg = ex.getMessage();
1137 return msg != null && msg.contains(EVENT_LISTENER_TIMEOUT);
1140 private boolean isOAuthGrantError(Exception ex) {
1141 String msg = ex.getMessage();
1143 && (msg.contains(AUTHENTICATION_OAUTH_GRANT_ERROR) || msg.contains(AUTHENTICATION_OAUTH_INVALID_GRANT));
1147 public void handleConfigurationUpdate(Map<String, Object> configurationParameters) {
1148 super.handleConfigurationUpdate(configurationParameters);
1149 if (configurationParameters.containsKey("email") || configurationParameters.containsKey("password")
1150 || configurationParameters.containsKey("portalUrl")) {
1151 reLoginNeeded = true;
1152 tooManyRequests = false;
1156 public synchronized void refresh(String url, String stateName) {
1157 SomfyTahomaState state = invokeCallToURL(DEVICES_URL + urlEncode(url) + "/states/" + stateName, "",
1158 HttpMethod.GET, SomfyTahomaState.class);
1159 if (state != null && !state.getName().isEmpty()) {
1160 updateDevice(url, List.of(state));
1164 private @Nullable <T> T invokeCallToURL(String url, String urlParameters, HttpMethod method,
1165 @Nullable Class<T> classOfT) {
1166 String response = "";
1170 response = sendGetToTahomaWithCookie(url);
1173 response = sendPutToTahomaWithCookie(url);
1176 response = sendPostToTahomaWithCookie(url, urlParameters);
1179 response = sendDeleteToTahomaWithCookie(url);
1183 return classOfT != null ? gson.fromJson(response, classOfT) : null;
1184 } catch (JsonSyntaxException e) {
1185 logger.debug("Received data: {} is not JSON", response, e);
1186 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Received invalid data");
1187 } catch (ExecutionException e) {
1188 if (isTempBanned(e)) {
1189 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Temporarily banned");
1190 setTooManyRequests();
1191 } else if (isEventListenerTimeout(e)) {
1192 logger.debug("Event listener timeout occurred", e);
1194 } else if (isDevModeReady()) {
1195 // the local gateway is unreachable
1197 logger.debug("Local gateway communication error", e);
1199 if (errorsCounter > MAX_ERRORS) {
1200 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
1201 "Too many communication errors");
1204 logger.debug("Cannot call url: {} with params: {}!", getApiFullUrl(url), urlParameters, e);
1205 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
1207 } catch (TimeoutException e) {
1209 logger.debug("Timeout when calling url: {} with params: {}!", getApiFullUrl(url), urlParameters, e);
1210 if (errorsCounter > MAX_ERRORS) {
1211 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Too many timeouts");
1213 } catch (InterruptedException e) {
1214 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
1215 Thread.currentThread().interrupt();
1220 public void setGatewayIPAddress(String gatewayIPAddress) {
1221 thingConfig.setIp(gatewayIPAddress);
1224 public void setGatewayPin(String gatewayPin) {
1225 thingConfig.setPin(gatewayPin);
1228 public void updateConfiguration() {
1229 Configuration config = editConfiguration();
1230 config.put("ip", thingConfig.getIp());
1231 config.put("pin", thingConfig.getPin());
1232 if (!localToken.isEmpty()) {
1233 config.put("token", localToken);
1235 updateConfiguration(config);