2 * Copyright (c) 2010-2023 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.util.Collection;
23 import java.util.Collections;
24 import java.util.HashMap;
25 import java.util.List;
27 import java.util.concurrent.ConcurrentLinkedQueue;
28 import java.util.concurrent.ExecutionException;
29 import java.util.concurrent.ScheduledFuture;
30 import java.util.concurrent.TimeUnit;
31 import java.util.concurrent.TimeoutException;
33 import javax.jmdns.JmDNS;
34 import javax.ws.rs.core.MediaType;
36 import org.eclipse.jdt.annotation.NonNullByDefault;
37 import org.eclipse.jdt.annotation.Nullable;
38 import org.eclipse.jetty.client.HttpClient;
39 import org.eclipse.jetty.client.WWWAuthenticationProtocolHandler;
40 import org.eclipse.jetty.client.api.ContentResponse;
41 import org.eclipse.jetty.client.api.Request;
42 import org.eclipse.jetty.client.util.StringContentProvider;
43 import org.eclipse.jetty.http.HttpHeader;
44 import org.eclipse.jetty.http.HttpMethod;
45 import org.eclipse.jetty.util.ssl.SslContextFactory;
46 import org.openhab.binding.somfytahoma.internal.config.SomfyTahomaConfig;
47 import org.openhab.binding.somfytahoma.internal.discovery.SomfyTahomaItemDiscoveryService;
48 import org.openhab.binding.somfytahoma.internal.discovery.SomfyTahomaMDNSDiscoveryListener;
49 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaAction;
50 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaActionGroup;
51 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaApplyResponse;
52 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaDevice;
53 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaError;
54 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaEvent;
55 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaLocalToken;
56 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaLoginResponse;
57 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaOauth2Error;
58 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaOauth2Reponse;
59 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaRegisterEventsResponse;
60 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaSetup;
61 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaState;
62 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaStatus;
63 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaStatusResponse;
64 import org.openhab.binding.somfytahoma.internal.model.SomfyTahomaTokenReponse;
65 import org.openhab.core.cache.ExpiringCache;
66 import org.openhab.core.config.core.Configuration;
67 import org.openhab.core.io.net.http.HttpClientFactory;
68 import org.openhab.core.thing.Bridge;
69 import org.openhab.core.thing.ChannelUID;
70 import org.openhab.core.thing.Thing;
71 import org.openhab.core.thing.ThingStatus;
72 import org.openhab.core.thing.ThingStatusDetail;
73 import org.openhab.core.thing.ThingStatusInfo;
74 import org.openhab.core.thing.binding.BaseBridgeHandler;
75 import org.openhab.core.thing.binding.ThingHandlerService;
76 import org.openhab.core.thing.util.ThingWebClientUtil;
77 import org.openhab.core.types.Command;
78 import org.slf4j.Logger;
79 import org.slf4j.LoggerFactory;
81 import com.google.gson.Gson;
82 import com.google.gson.JsonElement;
83 import com.google.gson.JsonSyntaxException;
86 * The {@link SomfyTahomaBridgeHandler} is responsible for handling commands, which are
87 * sent to one of the channels.
89 * @author Ondrej Pecta - Initial contribution
90 * @author Laurent Garnier - Other portals integration
93 public class SomfyTahomaBridgeHandler extends BaseBridgeHandler {
95 private final Logger logger = LoggerFactory.getLogger(SomfyTahomaBridgeHandler.class);
98 * The shared HttpClient
100 private @Nullable HttpClient httpClient;
103 * Future to poll for updates
105 private @Nullable ScheduledFuture<?> pollFuture;
108 * Future to poll for status
110 private @Nullable ScheduledFuture<?> statusFuture;
113 * Future to set reconciliation flag
115 private @Nullable ScheduledFuture<?> reconciliationFuture;
118 * Future for postponed login
120 private @Nullable ScheduledFuture<?> loginFuture;
122 // List of futures used for command retries
123 private Collection<ScheduledFuture<?>> retryFutures = new ConcurrentLinkedQueue<ScheduledFuture<?>>();
128 private Map<String, String> executions = new HashMap<>();
130 // Too many requests flag
131 private boolean tooManyRequests = false;
133 // Silent relogin flag
134 private boolean reLoginNeeded = false;
136 // Reconciliation flag
137 private boolean reconciliation = false;
140 private boolean cloudFallback = false;
145 protected SomfyTahomaConfig thingConfig = new SomfyTahomaConfig();
148 * Id of registered events
150 private String eventsId = "";
152 private String localToken = "";
154 private Map<String, SomfyTahomaDevice> devicePlaces = new HashMap<>();
156 private ExpiringCache<List<SomfyTahomaDevice>> cachedDevices = new ExpiringCache<>(Duration.ofSeconds(30),
160 private final Gson gson = new Gson();
162 private final HttpClientFactory httpClientFactory;
164 public SomfyTahomaBridgeHandler(Bridge thing, HttpClientFactory httpClientFactory) {
166 this.httpClientFactory = httpClientFactory;
170 public void handleCommand(ChannelUID channelUID, Command command) {
174 public void initialize() {
175 updateStatus(ThingStatus.UNKNOWN);
176 thingConfig = getConfigAs(SomfyTahomaConfig.class);
179 scheduler.execute(() -> {
182 logger.debug("Initialize done...");
186 private void createHttpClient() {
187 // let's create the right http client
188 String clientName = ThingWebClientUtil.buildWebClientConsumerName(thing.getUID(), null);
189 if (thingConfig.isDevMode()) {
190 this.httpClient = httpClientFactory.createHttpClient(clientName, new SslContextFactory.Client(true));
192 this.httpClient = httpClientFactory.createHttpClient(clientName);
197 } catch (Exception e) {
198 logger.debug("Cannot start http client for: {}", thing.getUID(), e);
201 // Remove the WWWAuth protocol handler since Tahoma is not fully compliant
202 httpClient.getProtocolHandlers().remove(WWWAuthenticationProtocolHandler.NAME);
206 * starts this things polling future
208 private void initPolling() {
210 scheduleGetUpdates(10);
212 statusFuture = scheduler.scheduleWithFixedDelay(() -> {
213 refreshTahomaStates();
214 }, 60, thingConfig.getStatusTimeout(), TimeUnit.SECONDS);
216 reconciliationFuture = scheduler.scheduleWithFixedDelay(() -> {
217 enableReconciliation();
218 }, RECONCILIATION_TIME, RECONCILIATION_TIME, TimeUnit.SECONDS);
221 private void scheduleGetUpdates(long delay) {
222 pollFuture = scheduler.schedule(() -> {
224 scheduleNextGetUpdates();
225 }, delay, TimeUnit.SECONDS);
228 private void scheduleNextGetUpdates() {
229 ScheduledFuture<?> localPollFuture = pollFuture;
230 if (localPollFuture != null) {
231 localPollFuture.cancel(false);
233 scheduleGetUpdates(executions.isEmpty() ? thingConfig.getRefresh() : 2);
236 public synchronized void login() {
237 if (thingConfig.getEmail().isEmpty() || thingConfig.getPassword().isEmpty()) {
238 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
239 "Can not access device as username and/or password are null");
243 if (tooManyRequests) {
244 logger.debug("Skipping login due to too many requests");
248 if (ThingStatus.ONLINE == thing.getStatus() && !reLoginNeeded) {
249 logger.debug("No need to log in, because already logged in");
253 reLoginNeeded = false;
254 cloudFallback = false;
257 String urlParameters = "";
259 // if cozytouch, must use oauth server
260 if (thingConfig.getCloudPortal().equalsIgnoreCase(COZYTOUCH_PORTAL)) {
261 logger.debug("CozyTouch Oauth2 authentication flow");
262 urlParameters = "jwt=" + loginCozytouch();
264 urlParameters = "userId=" + urlEncode(thingConfig.getEmail()) + "&userPassword="
265 + urlEncode(thingConfig.getPassword());
268 ContentResponse response = sendRequestBuilder("login", HttpMethod.POST)
269 .content(new StringContentProvider(urlParameters),
270 "application/x-www-form-urlencoded; charset=UTF-8")
273 if (logger.isTraceEnabled()) {
274 logger.trace("Login response: {}", response.getContentAsString());
277 SomfyTahomaLoginResponse data = gson.fromJson(response.getContentAsString(),
278 SomfyTahomaLoginResponse.class);
281 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
282 "Received invalid data (login)");
283 } else if (!data.getErrorCode().isEmpty()) {
284 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, data.getError());
285 if (data.getError().startsWith(TOO_MANY_REQUESTS)) {
286 setTooManyRequests();
289 if (thingConfig.isDevMode()) {
290 initializeLocalMode();
293 String id = registerEvents();
294 if (id != null && !UNAUTHORIZED.equals(id)) {
296 logger.debug("Events id: {}", eventsId);
297 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE,
298 isDevModeReady() ? "LAN mode" : cloudFallback ? "Cloud mode fallback" : "Cloud mode");
300 logger.debug("Events id error: {}", id);
301 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
302 "unable to register events");
305 } catch (JsonSyntaxException e) {
306 logger.debug("Received invalid data (login)", e);
307 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Received invalid data (login)");
308 } catch (ExecutionException e) {
309 if (isOAuthGrantError(e)) {
310 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
311 "Error logging in (check your credentials)");
312 setTooManyRequests();
314 logger.debug("Cannot get login cookie", e);
315 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Cannot get login cookie");
317 } catch (TimeoutException e) {
318 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Getting login cookie timeout");
319 } catch (InterruptedException e) {
320 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
321 "Getting login cookie interrupted");
322 Thread.currentThread().interrupt();
326 public boolean isDevModeReady() {
327 return thingConfig.isDevMode() && !localToken.isEmpty() && !cloudFallback;
330 private void initializeLocalMode() {
331 if (thingConfig.getIp().isEmpty() || thingConfig.getPin().isEmpty()) {
335 if (!thingConfig.getIp().isEmpty() && !thingConfig.getPin().isEmpty()) {
337 if (thingConfig.getToken().isEmpty()) {
338 localToken = getNewLocalToken();
339 logger.debug("Local token retrieved");
340 activateLocalToken();
341 updateConfiguration();
343 localToken = thingConfig.getToken();
344 activateLocalToken();
346 logger.debug("Local mode initialized, waiting for cloud sync");
348 } catch (InterruptedException ex) {
349 logger.debug("Interruption during local mode initialization, falling back to cloud mode", ex);
350 Thread.currentThread().interrupt();
351 } catch (ExecutionException | TimeoutException ex) {
352 logger.debug("Exception during local mode initialization, falling back to cloud mode", ex);
353 cloudFallback = true;
356 logger.debug("Cannot switch to developer mode - gateway not found on LAN");
357 cloudFallback = true;
361 private String getNewLocalToken() throws ExecutionException, InterruptedException, TimeoutException {
362 // Get list of local tokens
363 SomfyTahomaLocalToken[] tokens = invokeCallToURL(
364 CONFIG_URL + thingConfig.getPin() + LOCAL_TOKENS_URL + "devmode", "", HttpMethod.GET,
365 SomfyTahomaLocalToken[].class);
367 // Delete old OH tokens
368 for (SomfyTahomaLocalToken token : tokens) {
369 if (OPENHAB_TOKEN.equals(token.getLabel())) {
370 logger.debug("Deleting token: {}", token.getUuid());
371 sendDeleteToTahomaWithCookie(CONFIG_URL + thingConfig.getPin() + LOCAL_TOKENS_URL + token.getUuid());
375 // Generate a new token
376 SomfyTahomaTokenReponse tokenResponse = invokeCallToURL(
377 CONFIG_URL + thingConfig.getPin() + LOCAL_TOKENS_URL + "generate", "", HttpMethod.GET,
378 SomfyTahomaTokenReponse.class);
380 return tokenResponse.getToken();
383 private void discoverGateway() {
384 logger.debug("Starting mDNS discovery...");
388 // Create a JmDNS instance
389 jmdns = JmDNS.create(InetAddress.getLocalHost());
390 jmdns.addServiceListener("_kizboxdev._tcp.local.", new SomfyTahomaMDNSDiscoveryListener(this));
393 Thread.sleep(TAHOMA_TIMEOUT * 1000);
394 } catch (InterruptedException e) {
395 logger.debug("mDNS discovery interrupted", e);
396 Thread.currentThread().interrupt();
397 } catch (IOException e) {
398 logger.debug("Exception during mDNS discovery", e);
402 jmdns.unregisterAllServices();
405 } catch (IOException e) {
411 private void activateLocalToken() throws ExecutionException, InterruptedException, TimeoutException {
412 String param = "{\"label\" : \"" + OPENHAB_TOKEN + "\",\"token\" : \"" + localToken
413 + "\",\"scope\" : \"devmode\"}";
414 String response = sendPostToTahomaWithCookie(CONFIG_URL + thingConfig.getPin() + "/local/tokens", param);
415 logger.trace("Local token activation: {}", response);
418 private void setTooManyRequests() {
419 if (!tooManyRequests) {
421 "Too many requests or bad credentials for the cloud portal, suspending activity for {} seconds",
423 tooManyRequests = true;
424 loginFuture = scheduler.schedule(this::enableLogin, SUSPEND_TIME, TimeUnit.SECONDS);
428 private @Nullable String registerEvents() {
429 SomfyTahomaRegisterEventsResponse response = invokeCallToURL(EVENTS_URL + "register", "", HttpMethod.POST,
430 SomfyTahomaRegisterEventsResponse.class);
431 return response != null ? response.getId() : null;
434 private String urlEncode(String text) {
435 return URLEncoder.encode(text, StandardCharsets.UTF_8);
438 private void enableLogin() {
439 tooManyRequests = false;
442 private List<SomfyTahomaEvent> getEvents() {
443 if (eventsId.isEmpty()) {
447 SomfyTahomaEvent[] response = invokeCallToURL(EVENTS_URL + eventsId + "/fetch", "", HttpMethod.POST,
448 SomfyTahomaEvent[].class);
449 return response != null ? List.of(response) : List.of();
453 public void handleRemoval() {
454 super.handleRemoval();
459 public Collection<Class<? extends ThingHandlerService>> getServices() {
460 return Collections.singleton(SomfyTahomaItemDiscoveryService.class);
464 public void dispose() {
469 private void cleanup() {
470 logger.debug("Doing cleanup");
473 // cancel all scheduled retries
474 retryFutures.forEach(x -> x.cancel(false));
476 ScheduledFuture<?> localLoginFuture = loginFuture;
477 if (localLoginFuture != null) {
478 localLoginFuture.cancel(true);
482 HttpClient localHttpClient = httpClient;
483 if (localHttpClient != null) {
485 localHttpClient.stop();
486 } catch (Exception e) {
487 logger.debug("Error during http client stopping", e);
497 public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
498 super.bridgeStatusChanged(bridgeStatusInfo);
499 if (ThingStatus.UNINITIALIZED == bridgeStatusInfo.getStatus()) {
505 * Stops this thing's polling future
507 private void stopPolling() {
508 ScheduledFuture<?> localPollFuture = pollFuture;
509 if (localPollFuture != null) {
510 localPollFuture.cancel(true);
513 ScheduledFuture<?> localStatusFuture = statusFuture;
514 if (localStatusFuture != null) {
515 localStatusFuture.cancel(true);
518 ScheduledFuture<?> localReconciliationFuture = reconciliationFuture;
519 if (localReconciliationFuture != null) {
520 localReconciliationFuture.cancel(true);
521 reconciliationFuture = null;
525 public List<SomfyTahomaActionGroup> listActionGroups() {
526 SomfyTahomaActionGroup[] list = invokeCallToURL("actionGroups", "", HttpMethod.GET,
527 SomfyTahomaActionGroup[].class);
528 return list != null ? List.of(list) : List.of();
531 public @Nullable SomfyTahomaSetup getSetup() {
532 SomfyTahomaSetup setup = invokeCallToURL("setup", "", HttpMethod.GET, SomfyTahomaSetup.class);
534 saveDevicePlaces(setup.getDevices());
539 public List<SomfyTahomaDevice> getDevices() {
540 SomfyTahomaDevice[] response = invokeCallToURL(SETUP_URL + "devices", "", HttpMethod.GET,
541 SomfyTahomaDevice[].class);
542 List<SomfyTahomaDevice> devices = response != null ? List.of(response) : List.of();
543 saveDevicePlaces(devices);
547 public synchronized @Nullable SomfyTahomaDevice getCachedDevice(String url) {
548 List<SomfyTahomaDevice> devices = cachedDevices.getValue();
549 if (devices != null) {
550 for (SomfyTahomaDevice device : devices) {
551 if (url.equals(device.getDeviceURL())) {
559 private void saveDevicePlaces(List<SomfyTahomaDevice> devices) {
560 devicePlaces.clear();
561 for (SomfyTahomaDevice device : devices) {
562 if (!device.getPlaceOID().isEmpty()) {
563 SomfyTahomaDevice newDevice = new SomfyTahomaDevice();
564 newDevice.setPlaceOID(device.getPlaceOID());
565 newDevice.getDefinition().setWidgetName(device.getDefinition().getWidgetName());
566 devicePlaces.put(device.getDeviceURL(), newDevice);
571 private void getTahomaUpdates() {
572 logger.debug("Getting Tahoma Updates...");
573 if (ThingStatus.OFFLINE == thing.getStatus() && !reLogin()) {
577 List<SomfyTahomaEvent> events = getEvents();
578 logger.trace("Got total of {} events", events.size());
579 for (SomfyTahomaEvent event : events) {
584 private void processEvent(SomfyTahomaEvent event) {
585 logger.debug("Got event: {}", event.getName());
586 switch (event.getName()) {
587 case "ExecutionRegisteredEvent":
588 processExecutionRegisteredEvent(event);
590 case "ExecutionStateChangedEvent":
591 processExecutionChangedEvent(event);
593 case "DeviceStateChangedEvent":
594 processStateChangedEvent(event);
596 case "RefreshAllDevicesStatesCompletedEvent":
597 scheduler.schedule(this::updateThings, 1, TimeUnit.SECONDS);
599 case "GatewayAliveEvent":
600 case "GatewayDownEvent":
601 processGatewayEvent(event);
604 // ignore other states
608 private synchronized void updateThings() {
609 boolean needsUpdate = reconciliation;
611 for (Thing th : getThing().getThings()) {
612 if (th.isEnabled() && ThingStatus.ONLINE != th.getStatus()) {
617 // update all states only if necessary
620 reconciliation = false;
624 private void processExecutionRegisteredEvent(SomfyTahomaEvent event) {
625 boolean invalidData = false;
627 JsonElement el = event.getAction();
628 if (el.isJsonArray()) {
629 SomfyTahomaAction[] actions = gson.fromJson(el, SomfyTahomaAction[].class);
630 if (actions == null) {
633 for (SomfyTahomaAction action : actions) {
634 registerExecution(action.getDeviceURL(), event.getExecId());
638 SomfyTahomaAction action = gson.fromJson(el, SomfyTahomaAction.class);
639 if (action == null) {
642 registerExecution(action.getDeviceURL(), event.getExecId());
645 } catch (JsonSyntaxException e) {
649 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
650 "Received invalid data (execution registered)");
654 private void processExecutionChangedEvent(SomfyTahomaEvent event) {
655 if (FAILED_EVENT.equals(event.getNewState()) || COMPLETED_EVENT.equals(event.getNewState())) {
656 logger.debug("Removing execution id: {}", event.getExecId());
657 unregisterExecution(event.getExecId());
661 private void registerExecution(String url, String execId) {
662 if (executions.containsKey(url)) {
663 executions.remove(url);
664 logger.debug("Previous execution exists for url: {}", url);
666 executions.put(url, execId);
669 private void unregisterExecution(String execId) {
670 if (executions.containsValue(execId)) {
671 executions.values().removeAll(Collections.singleton(execId));
673 logger.debug("Cannot remove execution id: {}, because it is not registered", execId);
677 private void processGatewayEvent(SomfyTahomaEvent event) {
678 // update gateway status
679 for (Thing th : getThing().getThings()) {
680 if (th.isEnabled() && THING_TYPE_GATEWAY.equals(th.getThingTypeUID())) {
681 SomfyTahomaGatewayHandler gatewayHandler = (SomfyTahomaGatewayHandler) th.getHandler();
682 if (gatewayHandler != null && gatewayHandler.getGateWayId().equals(event.getGatewayId())) {
683 gatewayHandler.refresh(STATUS);
689 private synchronized void updateAllStates() {
690 logger.debug("Updating all states");
691 getDevices().forEach(device -> updateDevice(device));
694 private void updateDevice(SomfyTahomaDevice device) {
695 String url = device.getDeviceURL();
696 List<SomfyTahomaState> states = device.getStates();
697 updateDevice(url, states);
700 private void updateDevice(String url, List<SomfyTahomaState> states) {
701 Thing th = getThingByDeviceUrl(url);
705 SomfyTahomaBaseThingHandler handler = (SomfyTahomaBaseThingHandler) th.getHandler();
706 if (handler != null) {
707 handler.updateThingStatus(states);
708 handler.updateThingChannels(states);
712 private void processStateChangedEvent(SomfyTahomaEvent event) {
713 String deviceUrl = event.getDeviceUrl();
714 List<SomfyTahomaState> states = event.getDeviceStates();
715 logger.debug("States for device {} : {}", deviceUrl, states);
716 Thing thing = getThingByDeviceUrl(deviceUrl);
719 logger.debug("Updating status of thing: {}", thing.getLabel());
720 SomfyTahomaBaseThingHandler handler = (SomfyTahomaBaseThingHandler) thing.getHandler();
722 if (handler != null) {
723 // update thing status
724 handler.updateThingStatus(states);
725 handler.updateThingChannels(states);
728 logger.debug("Thing is disabled or handler is null, probably not bound thing.");
732 private void enableReconciliation() {
733 logger.debug("Enabling reconciliation");
734 reconciliation = true;
737 private void refreshTahomaStates() {
738 logger.debug("Refreshing Tahoma states...");
739 if (ThingStatus.OFFLINE == thing.getStatus() && !reLogin()) {
743 // force Tahoma to ask for actual states
747 private @Nullable Thing getThingByDeviceUrl(String deviceUrl) {
748 for (Thing th : getThing().getThings()) {
749 if (!th.isEnabled()) {
752 String url = (String) th.getConfiguration().get("url");
753 if (deviceUrl.equals(url)) {
760 private void logout() {
763 sendGetToTahomaWithCookie("logout");
764 } catch (ExecutionException | TimeoutException e) {
765 logger.debug("Cannot send logout command!", e);
766 } catch (InterruptedException e) {
767 Thread.currentThread().interrupt();
771 private String sendPostToTahomaWithCookie(String url, String urlParameters)
772 throws InterruptedException, ExecutionException, TimeoutException {
773 return sendMethodToTahomaWithCookie(url, HttpMethod.POST, urlParameters);
776 private String sendGetToTahomaWithCookie(String url)
777 throws InterruptedException, ExecutionException, TimeoutException {
778 return sendMethodToTahomaWithCookie(url, HttpMethod.GET);
781 private String sendPutToTahomaWithCookie(String url)
782 throws InterruptedException, ExecutionException, TimeoutException {
783 return sendMethodToTahomaWithCookie(url, HttpMethod.PUT);
786 private String sendDeleteToTahomaWithCookie(String url)
787 throws InterruptedException, ExecutionException, TimeoutException {
788 return sendMethodToTahomaWithCookie(url, HttpMethod.DELETE);
791 private String sendMethodToTahomaWithCookie(String url, HttpMethod method)
792 throws InterruptedException, ExecutionException, TimeoutException {
793 return sendMethodToTahomaWithCookie(url, method, "");
796 private String sendMethodToTahomaWithCookie(String url, HttpMethod method, String urlParameters)
797 throws InterruptedException, ExecutionException, TimeoutException {
798 logger.debug("Sending {} to url: {} with data: {}", method.asString(), getApiFullUrl(url), urlParameters);
799 Request request = sendRequestBuilder(url, method);
800 if (!urlParameters.isEmpty()) {
801 request = request.content(new StringContentProvider(urlParameters), "application/json");
804 ContentResponse response = request.send();
806 if (logger.isTraceEnabled()) {
807 logger.trace("Response: {}", response.getContentAsString());
810 if (response.getStatus() < 200 || response.getStatus() >= 300) {
811 logger.debug("Received unexpected status code: {}", response.getStatus());
812 if (response.getHeaders().contains(HttpHeader.CONTENT_TYPE)) {
813 if (response.getHeaders().getField(HttpHeader.CONTENT_TYPE).getValue()
814 .equalsIgnoreCase(MediaType.APPLICATION_JSON)) {
816 SomfyTahomaError error = gson.fromJson(response.getContentAsString(), SomfyTahomaError.class);
817 throw new ExecutionException(error.getError(), null);
818 } catch (JsonSyntaxException e) {
823 throw new ExecutionException(
824 "Unknown http error " + response.getStatus() + " while attempting to send a message.", null);
826 return response.getContentAsString();
829 private Request sendRequestBuilder(String subUrl, HttpMethod method) {
830 return isLocalRequest(subUrl) ? sendRequestBuilderLocal(subUrl, method)
831 : sendRequestBuilderCloud(subUrl, method);
834 private boolean isLocalRequest(String subUrl) {
835 return isDevModeReady() && !subUrl.startsWith(CONFIG_URL);
838 private Request sendRequestBuilderCloud(String subUrl, HttpMethod method) {
839 return httpClient.newRequest(getApiFullUrl(subUrl)).method(method)
840 .header(HttpHeader.ACCEPT_LANGUAGE, "en-US,en").header(HttpHeader.ACCEPT_ENCODING, "gzip, deflate")
841 .header("X-Requested-With", "XMLHttpRequest").timeout(TAHOMA_TIMEOUT, TimeUnit.SECONDS)
842 .agent(TAHOMA_AGENT);
845 private Request sendRequestBuilderLocal(String subUrl, HttpMethod method) {
846 return httpClient.newRequest(getApiFullUrl(subUrl)).method(method).accept("application/json")
847 .header(HttpHeader.AUTHORIZATION, "Bearer " + localToken);
851 * Performs the login for Cozytouch using OAUTH2 authorization.
853 * @return JSESSION ID cookie value.
854 * @throws ExecutionException
855 * @throws TimeoutException
856 * @throws InterruptedException
857 * @throws JsonSyntaxException
859 private String loginCozytouch()
860 throws InterruptedException, TimeoutException, ExecutionException, JsonSyntaxException {
861 String authBaseUrl = "https://" + COZYTOUCH_OAUTH2_URL;
863 String urlParameters = "grant_type=password&username=" + urlEncode(thingConfig.getEmail()) + "&password="
864 + urlEncode(thingConfig.getPassword());
866 ContentResponse response = httpClient.newRequest(authBaseUrl + COZYTOUCH_OAUTH2_TOKEN_URL)
867 .method(HttpMethod.POST).header(HttpHeader.ACCEPT_LANGUAGE, "en-US,en")
868 .header(HttpHeader.ACCEPT_ENCODING, "gzip, deflate").header("X-Requested-With", "XMLHttpRequest")
869 .header(HttpHeader.AUTHORIZATION, "Basic " + COZYTOUCH_OAUTH2_BASICAUTH)
870 .timeout(TAHOMA_TIMEOUT, TimeUnit.SECONDS).agent(TAHOMA_AGENT)
871 .content(new StringContentProvider(urlParameters), "application/x-www-form-urlencoded; charset=UTF-8")
874 if (response.getStatus() != 200) {
876 if (response.getHeaders().getField(HttpHeader.CONTENT_TYPE).getValue()
877 .equalsIgnoreCase(MediaType.APPLICATION_JSON)) {
879 SomfyTahomaOauth2Error error = gson.fromJson(response.getContentAsString(),
880 SomfyTahomaOauth2Error.class);
881 throw new ExecutionException(error.getErrorDescription(), null);
882 } catch (JsonSyntaxException e) {
886 throw new ExecutionException("Unknown error while attempting to log in.", null);
889 SomfyTahomaOauth2Reponse oauth2response = gson.fromJson(response.getContentAsString(),
890 SomfyTahomaOauth2Reponse.class);
892 logger.debug("OAuth2 Access Token: {}", oauth2response.getAccessToken());
894 response = httpClient.newRequest(authBaseUrl + COZYTOUCH_OAUTH2_JWT_URL).method(HttpMethod.GET)
895 .header(HttpHeader.AUTHORIZATION, "Bearer " + oauth2response.getAccessToken())
896 .timeout(TAHOMA_TIMEOUT, TimeUnit.SECONDS).send();
898 if (response.getStatus() == 200) {
899 String jwt = response.getContentAsString();
900 return jwt.replace("\"", "");
902 throw new ExecutionException(String.format("Failed to retrieve JWT token. ResponseCode=%d, ResponseText=%s",
903 response.getStatus(), response.getContentAsString()), null);
907 private String getApiFullUrl(String subUrl) {
908 return isLocalRequest(subUrl)
909 ? "https://" + thingConfig.getIp() + ":8443/enduser-mobile-web/1/enduserAPI/" + subUrl
910 : "https://" + thingConfig.getCloudPortal() + API_BASE_URL + subUrl;
913 public void sendCommand(String io, String command, String params, String url) {
914 if (ThingStatus.OFFLINE == thing.getStatus() && !reLogin()) {
918 removeFinishedRetries();
920 boolean result = sendCommandInternal(io, command, params, url);
922 scheduleRetry(io, command, params, url, thingConfig.getRetries());
926 private void repeatSendCommandInternal(String io, String command, String params, String url, int retries) {
927 logger.debug("Retrying command, retries left: {}", retries);
928 boolean result = sendCommandInternal(io, command, params, url);
929 if (!result && (retries > 0)) {
930 scheduleRetry(io, command, params, url, retries - 1);
934 private boolean sendCommandInternal(String io, String command, String params, String url) {
935 String value = "[]".equals(params) ? command : command + " " + params.replace("\"", "");
936 String urlParameters = "{\"label\":\"" + getThingLabelByURL(io) + " - " + value
937 + " - openHAB\",\"actions\":[{\"deviceURL\":\"" + io + "\",\"commands\":[{\"name\":\"" + command
938 + "\",\"parameters\":" + params + "}]}]}";
939 SomfyTahomaApplyResponse response = invokeCallToURL(url, urlParameters, HttpMethod.POST,
940 SomfyTahomaApplyResponse.class);
941 if (response != null) {
942 if (!response.getExecId().isEmpty()) {
943 logger.debug("Exec id: {}", response.getExecId());
944 registerExecution(io, response.getExecId());
945 scheduleNextGetUpdates();
947 logger.debug("ExecId is empty!");
955 private void removeFinishedRetries() {
956 retryFutures.removeIf(x -> x.isDone());
957 logger.debug("Currently {} retries are scheduled.", retryFutures.size());
960 private void scheduleRetry(String io, String command, String params, String url, int retries) {
961 retryFutures.add(scheduler.schedule(() -> {
962 repeatSendCommandInternal(io, command, params, url, retries);
963 }, thingConfig.getRetryDelay(), TimeUnit.MILLISECONDS));
966 public void sendCommandToSameDevicesInPlace(String io, String command, String params, String url) {
967 SomfyTahomaDevice device = devicePlaces.get(io);
968 if (device != null && !device.getPlaceOID().isEmpty()) {
969 devicePlaces.forEach((deviceUrl, devicePlace) -> {
970 if (device.getPlaceOID().equals(devicePlace.getPlaceOID())
971 && device.getDefinition().getWidgetName().equals(devicePlace.getDefinition().getWidgetName())) {
972 sendCommand(deviceUrl, command, params, url);
976 sendCommand(io, command, params, url);
980 private String getThingLabelByURL(String io) {
981 Thing th = getThingByDeviceUrl(io);
983 if (th.getProperties().containsKey(NAME_STATE)) {
984 // Return label from Tahoma
985 return th.getProperties().get(NAME_STATE).replace("\"", "");
987 // Return label from the thing
988 String label = th.getLabel();
989 return label != null ? label.replace("\"", "") : "";
994 public @Nullable String getCurrentExecutions(String io) {
995 if (executions.containsKey(io)) {
996 return executions.get(io);
1001 public void cancelExecution(String executionId) {
1002 invokeCallToURL(DELETE_URL + executionId, "", HttpMethod.DELETE, null);
1005 public void executeActionGroup(String id) {
1006 if (ThingStatus.OFFLINE == thing.getStatus() && !reLogin()) {
1009 String execId = executeActionGroupInternal(id);
1010 if (execId == null) {
1011 execId = executeActionGroupInternal(id);
1013 if (execId != null) {
1014 registerExecution(id, execId);
1015 scheduleNextGetUpdates();
1019 private boolean reLogin() {
1020 logger.debug("Doing relogin");
1021 reLoginNeeded = true;
1024 return ThingStatus.OFFLINE != thing.getStatus();
1027 public @Nullable String executeActionGroupInternal(String id) {
1028 SomfyTahomaApplyResponse response = invokeCallToURL(EXEC_URL + id, "", HttpMethod.POST,
1029 SomfyTahomaApplyResponse.class);
1030 if (response != null) {
1031 if (response.getExecId().isEmpty()) {
1032 logger.debug("Got empty exec response");
1035 return response.getExecId();
1040 public void forceGatewaySync() {
1041 // refresh is valid only if in a cloud mode
1042 if (!thingConfig.isDevMode() || localToken.isEmpty()) {
1043 invokeCallToURL(REFRESH_URL, "", HttpMethod.PUT, null);
1047 public SomfyTahomaStatus getTahomaStatus(String gatewayId) {
1048 SomfyTahomaStatusResponse status = null;
1050 if (isDevModeReady()) {
1051 // Local endpoint does not have a method for specific gateway
1052 SomfyTahomaStatusResponse[] data = invokeCallToURL(GATEWAYS_URL, "", HttpMethod.GET,
1053 SomfyTahomaStatusResponse[].class);
1055 for (SomfyTahomaStatusResponse gatewayStatus : data) {
1056 if (gatewayStatus.getGatewayId().equals(gatewayId)) {
1057 status = gatewayStatus;
1063 status = invokeCallToURL(GATEWAYS_URL + gatewayId, "", HttpMethod.GET, SomfyTahomaStatusResponse.class);
1066 if (status != null) {
1067 logger.debug("Tahoma status: {}", status.getConnectivity().getStatus());
1068 logger.debug("Tahoma protocol version: {}", status.getConnectivity().getProtocolVersion());
1069 return status.getConnectivity();
1071 return new SomfyTahomaStatus();
1074 private boolean isTempBanned(Exception ex) {
1075 String msg = ex.getMessage();
1076 return msg != null && msg.contains(TEMPORARILY_BANNED);
1079 private boolean isEventListenerTimeout(Exception ex) {
1080 String msg = ex.getMessage();
1081 return msg != null && msg.contains(EVENT_LISTENER_TIMEOUT);
1084 private boolean isOAuthGrantError(Exception ex) {
1085 String msg = ex.getMessage();
1087 && (msg.contains(AUTHENTICATION_OAUTH_GRANT_ERROR) || msg.contains(AUTHENTICATION_OAUTH_INVALID_GRANT));
1091 public void handleConfigurationUpdate(Map<String, Object> configurationParameters) {
1092 super.handleConfigurationUpdate(configurationParameters);
1093 if (configurationParameters.containsKey("email") || configurationParameters.containsKey("password")
1094 || configurationParameters.containsKey("portalUrl")) {
1095 reLoginNeeded = true;
1096 tooManyRequests = false;
1100 public synchronized void refresh(String url, String stateName) {
1101 SomfyTahomaState state = invokeCallToURL(DEVICES_URL + urlEncode(url) + "/states/" + stateName, "",
1102 HttpMethod.GET, SomfyTahomaState.class);
1103 if (state != null && !state.getName().isEmpty()) {
1104 updateDevice(url, List.of(state));
1108 private @Nullable <T> T invokeCallToURL(String url, String urlParameters, HttpMethod method,
1109 @Nullable Class<T> classOfT) {
1110 String response = "";
1114 response = sendGetToTahomaWithCookie(url);
1117 response = sendPutToTahomaWithCookie(url);
1120 response = sendPostToTahomaWithCookie(url, urlParameters);
1123 response = sendDeleteToTahomaWithCookie(url);
1126 return classOfT != null ? gson.fromJson(response, classOfT) : null;
1127 } catch (JsonSyntaxException e) {
1128 logger.debug("Received data: {} is not JSON", response, e);
1129 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Received invalid data");
1130 } catch (ExecutionException e) {
1131 if (isTempBanned(e)) {
1132 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Temporarily banned");
1133 setTooManyRequests();
1134 } else if (isEventListenerTimeout(e)) {
1137 logger.debug("Cannot call url: {} with params: {}!", getApiFullUrl(url), urlParameters, e);
1138 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
1140 } catch (TimeoutException e) {
1141 logger.debug("Timeout when calling url: {} with params: {}!", getApiFullUrl(url), urlParameters, e);
1142 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
1143 } catch (InterruptedException e) {
1144 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
1145 Thread.currentThread().interrupt();
1150 public void setGatewayIPAddress(String gatewayIPAddress) {
1151 thingConfig.setIp(gatewayIPAddress);
1154 public void setGatewayPin(String gatewayPin) {
1155 thingConfig.setPin(gatewayPin);
1158 public void updateConfiguration() {
1159 Configuration config = editConfiguration();
1160 config.put("ip", thingConfig.getIp());
1161 config.put("pin", thingConfig.getPin());
1162 if (!localToken.isEmpty()) {
1163 config.put("token", localToken);
1165 updateConfiguration(config);