2 * Copyright (c) 2010-2021 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.UnsupportedEncodingException;
18 import java.net.URLEncoder;
19 import java.nio.charset.StandardCharsets;
21 import java.util.concurrent.ConcurrentLinkedQueue;
22 import java.util.concurrent.ExecutionException;
23 import java.util.concurrent.ScheduledFuture;
24 import java.util.concurrent.TimeUnit;
25 import java.util.concurrent.TimeoutException;
27 import org.apache.commons.lang.StringUtils;
28 import org.eclipse.jdt.annotation.NonNullByDefault;
29 import org.eclipse.jdt.annotation.Nullable;
30 import org.eclipse.jetty.client.HttpClient;
31 import org.eclipse.jetty.client.api.ContentResponse;
32 import org.eclipse.jetty.client.api.Request;
33 import org.eclipse.jetty.client.util.StringContentProvider;
34 import org.eclipse.jetty.http.HttpHeader;
35 import org.eclipse.jetty.http.HttpMethod;
36 import org.openhab.binding.somfytahoma.internal.config.SomfyTahomaConfig;
37 import org.openhab.binding.somfytahoma.internal.discovery.SomfyTahomaItemDiscoveryService;
38 import org.openhab.binding.somfytahoma.internal.model.*;
39 import org.openhab.core.io.net.http.HttpClientFactory;
40 import org.openhab.core.thing.*;
41 import org.openhab.core.thing.binding.BaseBridgeHandler;
42 import org.openhab.core.thing.binding.ThingHandlerService;
43 import org.openhab.core.types.Command;
44 import org.slf4j.Logger;
45 import org.slf4j.LoggerFactory;
47 import com.google.gson.Gson;
48 import com.google.gson.JsonElement;
49 import com.google.gson.JsonSyntaxException;
52 * The {@link SomfyTahomaBridgeHandler} is responsible for handling commands, which are
53 * sent to one of the channels.
55 * @author Ondrej Pecta - Initial contribution
58 public class SomfyTahomaBridgeHandler extends BaseBridgeHandler {
60 private final Logger logger = LoggerFactory.getLogger(SomfyTahomaBridgeHandler.class);
63 * The shared HttpClient
65 private final HttpClient httpClient;
68 * Future to poll for updates
70 private @Nullable ScheduledFuture<?> pollFuture;
73 * Future to poll for status
75 private @Nullable ScheduledFuture<?> statusFuture;
78 * Future to set reconciliation flag
80 private @Nullable ScheduledFuture<?> reconciliationFuture;
82 // List of futures used for command retries
83 private Collection<ScheduledFuture<?>> retryFutures = new ConcurrentLinkedQueue<ScheduledFuture<?>>();
88 private Map<String, String> executions = new HashMap<>();
90 // Too many requests flag
91 private boolean tooManyRequests = false;
93 // Silent relogin flag
94 private boolean reLoginNeeded = false;
96 // Reconciliation flag
97 private boolean reconciliation = false;
102 protected SomfyTahomaConfig thingConfig = new SomfyTahomaConfig();
105 * Id of registered events
107 private String eventsId = "";
110 private final Gson gson = new Gson();
112 public SomfyTahomaBridgeHandler(Bridge thing, HttpClientFactory httpClientFactory) {
114 this.httpClient = httpClientFactory.createHttpClient("somfy_" + thing.getUID().getId());
118 public void handleCommand(ChannelUID channelUID, Command command) {
122 public void initialize() {
123 thingConfig = getConfigAs(SomfyTahomaConfig.class);
127 } catch (Exception e) {
128 logger.debug("Cannot start http client for: {}", thing.getBridgeUID().getId(), e);
132 scheduler.execute(() -> {
135 logger.debug("Initialize done...");
140 * starts this things polling future
142 private void initPolling() {
144 pollFuture = scheduler.scheduleWithFixedDelay(() -> {
146 }, 10, thingConfig.getRefresh(), TimeUnit.SECONDS);
148 statusFuture = scheduler.scheduleWithFixedDelay(() -> {
149 refreshTahomaStates();
150 }, 60, thingConfig.getStatusTimeout(), TimeUnit.SECONDS);
152 reconciliationFuture = scheduler.scheduleWithFixedDelay(() -> {
153 enableReconciliation();
154 }, RECONCILIATION_TIME, RECONCILIATION_TIME, TimeUnit.SECONDS);
157 public synchronized void login() {
160 if (StringUtils.isEmpty(thingConfig.getEmail()) || StringUtils.isEmpty(thingConfig.getPassword())) {
161 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
162 "Can not access device as username and/or password are null");
166 if (tooManyRequests) {
167 logger.debug("Skipping login due to too many requests");
171 if (ThingStatus.ONLINE == thing.getStatus() && !reLoginNeeded) {
172 logger.debug("No need to log in, because already logged in");
176 reLoginNeeded = false;
179 url = TAHOMA_API_URL + "login";
180 String urlParameters = "userId=" + urlEncode(thingConfig.getEmail()) + "&userPassword="
181 + urlEncode(thingConfig.getPassword());
183 ContentResponse response = sendRequestBuilder(url, HttpMethod.POST)
184 .content(new StringContentProvider(urlParameters),
185 "application/x-www-form-urlencoded; charset=UTF-8")
188 if (logger.isTraceEnabled()) {
189 logger.trace("Login response: {}", response.getContentAsString());
192 SomfyTahomaLoginResponse data = gson.fromJson(response.getContentAsString(),
193 SomfyTahomaLoginResponse.class);
194 if (data.isSuccess()) {
195 logger.debug("SomfyTahoma version: {}", data.getVersion());
196 String id = registerEvents();
197 if (id != null && !id.equals(UNAUTHORIZED)) {
199 logger.debug("Events id: {}", eventsId);
200 updateStatus(ThingStatus.ONLINE);
202 logger.debug("Events id error: {}", id);
205 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
206 "Error logging in: " + data.getError());
207 if (data.getError().startsWith(TOO_MANY_REQUESTS)) {
208 setTooManyRequests();
211 } catch (JsonSyntaxException e) {
212 logger.debug("Received invalid data", e);
213 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Received invalid data");
214 } catch (InterruptedException | ExecutionException | TimeoutException e) {
215 if (e instanceof ExecutionException) {
216 if (e.getMessage().contains(AUTHENTICATION_CHALLENGE)) {
217 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
218 "Authentication challenge");
219 setTooManyRequests();
223 logger.debug("Cannot get login cookie!", e);
224 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Cannot get login cookie");
225 if (e instanceof InterruptedException) {
226 Thread.currentThread().interrupt();
231 private void setTooManyRequests() {
232 logger.debug("Too many requests error, suspending activity for {} seconds", SUSPEND_TIME);
233 tooManyRequests = true;
234 scheduler.schedule(this::enableLogin, SUSPEND_TIME, TimeUnit.SECONDS);
237 private @Nullable String registerEvents() {
238 SomfyTahomaRegisterEventsResponse response = invokeCallToURL(TAHOMA_EVENTS_URL + "register", "",
239 HttpMethod.POST, SomfyTahomaRegisterEventsResponse.class);
240 return response != null ? response.getId() : null;
243 private String urlEncode(String text) {
245 return URLEncoder.encode(text, StandardCharsets.UTF_8.toString());
246 } catch (UnsupportedEncodingException e) {
251 private void enableLogin() {
252 tooManyRequests = false;
255 private List<SomfyTahomaEvent> getEvents() {
256 SomfyTahomaEvent[] response = invokeCallToURL(TAHOMA_API_URL + "events/" + eventsId + "/fetch", "",
257 HttpMethod.POST, SomfyTahomaEvent[].class);
258 return response != null ? List.of(response) : List.of();
262 public void handleRemoval() {
263 super.handleRemoval();
268 public Collection<Class<? extends ThingHandlerService>> getServices() {
269 return Collections.singleton(SomfyTahomaItemDiscoveryService.class);
273 public void dispose() {
278 private void cleanup() {
279 logger.debug("Doing cleanup");
282 // cancel all scheduled retries
283 retryFutures.forEach(x -> x.cancel(false));
287 } catch (Exception e) {
288 logger.debug("Error during http client stopping", e);
293 public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
294 super.bridgeStatusChanged(bridgeStatusInfo);
295 if (ThingStatus.UNINITIALIZED == bridgeStatusInfo.getStatus()) {
301 * Stops this thing's polling future
303 private void stopPolling() {
304 ScheduledFuture<?> localPollFuture = pollFuture;
305 if (localPollFuture != null && !localPollFuture.isCancelled()) {
306 localPollFuture.cancel(true);
308 ScheduledFuture<?> localStatusFuture = statusFuture;
309 if (localStatusFuture != null && !localStatusFuture.isCancelled()) {
310 localStatusFuture.cancel(true);
312 ScheduledFuture<?> localReconciliationFuture = reconciliationFuture;
313 if (localReconciliationFuture != null && !localReconciliationFuture.isCancelled()) {
314 localReconciliationFuture.cancel(true);
318 public List<SomfyTahomaActionGroup> listActionGroups() {
319 SomfyTahomaActionGroup[] list = invokeCallToURL(TAHOMA_API_URL + "actionGroups", "", HttpMethod.GET,
320 SomfyTahomaActionGroup[].class);
321 return list != null ? List.of(list) : List.of();
324 public @Nullable SomfyTahomaSetup getSetup() {
325 return invokeCallToURL(TAHOMA_API_URL + "setup", "", HttpMethod.GET, SomfyTahomaSetup.class);
328 public List<SomfyTahomaDevice> getDevices() {
329 SomfyTahomaDevice[] response = invokeCallToURL(SETUP_URL + "devices", "", HttpMethod.GET,
330 SomfyTahomaDevice[].class);
331 return response != null ? List.of(response) : List.of();
334 private void getTahomaUpdates() {
335 logger.debug("Getting Tahoma Updates...");
336 if (ThingStatus.OFFLINE == thing.getStatus() && !reLogin()) {
340 List<SomfyTahomaEvent> events = getEvents();
341 logger.trace("Got total of {} events", events.size());
342 for (SomfyTahomaEvent event : events) {
347 private void processEvent(SomfyTahomaEvent event) {
348 logger.debug("Got event: {}", event.getName());
349 switch (event.getName()) {
350 case "ExecutionRegisteredEvent":
351 processExecutionRegisteredEvent(event);
353 case "ExecutionStateChangedEvent":
354 processExecutionChangedEvent(event);
356 case "DeviceStateChangedEvent":
357 processStateChangedEvent(event);
359 case "RefreshAllDevicesStatesCompletedEvent":
360 scheduler.schedule(this::updateThings, 1, TimeUnit.SECONDS);
362 case "GatewayAliveEvent":
363 case "GatewayDownEvent":
364 processGatewayEvent(event);
367 // ignore other states
371 private synchronized void updateThings() {
372 boolean needsUpdate = reconciliation;
374 for (Thing th : getThing().getThings()) {
375 if (ThingStatus.ONLINE != th.getStatus()) {
380 // update all states only if necessary
383 reconciliation = false;
387 private void processExecutionRegisteredEvent(SomfyTahomaEvent event) {
388 JsonElement el = event.getAction();
389 if (el.isJsonArray()) {
390 SomfyTahomaAction[] actions = gson.fromJson(el, SomfyTahomaAction[].class);
391 for (SomfyTahomaAction action : actions) {
392 registerExecution(action.getDeviceURL(), event.getExecId());
395 SomfyTahomaAction action = gson.fromJson(el, SomfyTahomaAction.class);
396 registerExecution(action.getDeviceURL(), event.getExecId());
400 private void processExecutionChangedEvent(SomfyTahomaEvent event) {
401 if (FAILED_EVENT.equals(event.getNewState()) || COMPLETED_EVENT.equals(event.getNewState())) {
402 logger.debug("Removing execution id: {}", event.getExecId());
403 unregisterExecution(event.getExecId());
407 private void registerExecution(String url, String execId) {
408 if (executions.containsKey(url)) {
409 executions.remove(url);
410 logger.debug("Previous execution exists for url: {}", url);
412 executions.put(url, execId);
415 private void unregisterExecution(String execId) {
416 if (executions.containsValue(execId)) {
417 executions.values().removeAll(Collections.singleton(execId));
419 logger.debug("Cannot remove execution id: {}, because it is not registered", execId);
423 private void processGatewayEvent(SomfyTahomaEvent event) {
424 // update gateway status
425 for (Thing th : getThing().getThings()) {
426 if (THING_TYPE_GATEWAY.equals(th.getThingTypeUID())) {
427 SomfyTahomaGatewayHandler gatewayHandler = (SomfyTahomaGatewayHandler) th.getHandler();
428 if (gatewayHandler != null && gatewayHandler.getGateWayId().equals(event.getGatewayId())) {
429 gatewayHandler.refresh(STATUS);
435 private synchronized void updateAllStates() {
436 logger.debug("Updating all states");
437 getDevices().forEach(device -> updateDevice(device));
440 private void updateDevice(SomfyTahomaDevice device) {
441 String url = device.getDeviceURL();
442 List<SomfyTahomaState> states = device.getStates();
443 updateDevice(url, states);
446 private void updateDevice(String url, List<SomfyTahomaState> states) {
447 Thing th = getThingByDeviceUrl(url);
451 SomfyTahomaBaseThingHandler handler = (SomfyTahomaBaseThingHandler) th.getHandler();
452 if (handler != null) {
453 handler.updateThingStatus(states);
454 handler.updateThingChannels(states);
458 private void processStateChangedEvent(SomfyTahomaEvent event) {
459 String deviceUrl = event.getDeviceUrl();
460 List<SomfyTahomaState> states = event.getDeviceStates();
461 logger.debug("States for device {} : {}", deviceUrl, states);
462 Thing thing = getThingByDeviceUrl(deviceUrl);
465 logger.debug("Updating status of thing: {}", thing.getLabel());
466 SomfyTahomaBaseThingHandler handler = (SomfyTahomaBaseThingHandler) thing.getHandler();
468 if (handler != null) {
469 // update thing status
470 handler.updateThingStatus(states);
471 handler.updateThingChannels(states);
474 logger.debug("Thing handler is null, probably not bound thing.");
478 private void enableReconciliation() {
479 logger.debug("Enabling reconciliation");
480 reconciliation = true;
483 private void refreshTahomaStates() {
484 logger.debug("Refreshing Tahoma states...");
485 if (ThingStatus.OFFLINE == thing.getStatus() && !reLogin()) {
489 // force Tahoma to ask for actual states
493 private @Nullable Thing getThingByDeviceUrl(String deviceUrl) {
494 for (Thing th : getThing().getThings()) {
495 String url = (String) th.getConfiguration().get("url");
496 if (deviceUrl.equals(url)) {
503 private void logout() {
506 sendGetToTahomaWithCookie(TAHOMA_API_URL + "logout");
507 } catch (InterruptedException | ExecutionException | TimeoutException e) {
508 logger.debug("Cannot send logout command!", e);
509 if (e instanceof InterruptedException) {
510 Thread.currentThread().interrupt();
515 private String sendPostToTahomaWithCookie(String url, String urlParameters)
516 throws InterruptedException, ExecutionException, TimeoutException {
517 return sendMethodToTahomaWithCookie(url, HttpMethod.POST, urlParameters);
520 private String sendGetToTahomaWithCookie(String url)
521 throws InterruptedException, ExecutionException, TimeoutException {
522 return sendMethodToTahomaWithCookie(url, HttpMethod.GET);
525 private String sendPutToTahomaWithCookie(String url)
526 throws InterruptedException, ExecutionException, TimeoutException {
527 return sendMethodToTahomaWithCookie(url, HttpMethod.PUT);
530 private String sendDeleteToTahomaWithCookie(String url)
531 throws InterruptedException, ExecutionException, TimeoutException {
532 return sendMethodToTahomaWithCookie(url, HttpMethod.DELETE);
535 private String sendMethodToTahomaWithCookie(String url, HttpMethod method)
536 throws InterruptedException, ExecutionException, TimeoutException {
537 return sendMethodToTahomaWithCookie(url, method, "");
540 private String sendMethodToTahomaWithCookie(String url, HttpMethod method, String urlParameters)
541 throws InterruptedException, ExecutionException, TimeoutException {
542 logger.trace("Sending {} to url: {} with data: {}", method.asString(), url, urlParameters);
543 Request request = sendRequestBuilder(url, method);
544 if (StringUtils.isNotEmpty(urlParameters)) {
545 request = request.content(new StringContentProvider(urlParameters), "application/json;charset=UTF-8");
548 ContentResponse response = request.send();
550 if (logger.isTraceEnabled()) {
551 logger.trace("Response: {}", response.getContentAsString());
554 if (response.getStatus() < 200 || response.getStatus() >= 300) {
555 logger.debug("Received unexpected status code: {}", response.getStatus());
557 return response.getContentAsString();
560 private Request sendRequestBuilder(String url, HttpMethod method) {
561 return httpClient.newRequest(url).method(method).header(HttpHeader.ACCEPT_LANGUAGE, "en-US,en")
562 .header(HttpHeader.ACCEPT_ENCODING, "gzip, deflate").header("X-Requested-With", "XMLHttpRequest")
563 .timeout(TAHOMA_TIMEOUT, TimeUnit.SECONDS).agent(TAHOMA_AGENT);
566 public void sendCommand(String io, String command, String params, String url) {
567 if (ThingStatus.OFFLINE == thing.getStatus() && !reLogin()) {
571 removeFinishedRetries();
573 boolean result = sendCommandInternal(io, command, params, url);
575 scheduleRetry(io, command, params, url, thingConfig.getRetries());
579 private void repeatSendCommandInternal(String io, String command, String params, String url, int retries) {
580 logger.debug("Retrying command, retries left: {}", retries);
581 boolean result = sendCommandInternal(io, command, params, url);
582 if (!result && (retries > 0)) {
583 scheduleRetry(io, command, params, url, retries - 1);
587 private boolean sendCommandInternal(String io, String command, String params, String url) {
588 String value = params.equals("[]") ? command : params.replace("\"", "");
589 String urlParameters = "{\"label\":\"" + getThingLabelByURL(io) + " - " + value
590 + " - OH2\",\"actions\":[{\"deviceURL\":\"" + io + "\",\"commands\":[{\"name\":\"" + command
591 + "\",\"parameters\":" + params + "}]}]}";
592 SomfyTahomaApplyResponse response = invokeCallToURL(url, urlParameters, HttpMethod.POST,
593 SomfyTahomaApplyResponse.class);
594 if (response != null) {
595 if (!response.getExecId().isEmpty()) {
596 logger.debug("Exec id: {}", response.getExecId());
597 registerExecution(io, response.getExecId());
599 logger.debug("ExecId is empty!");
607 private void removeFinishedRetries() {
608 retryFutures.removeIf(x -> x.isDone());
609 logger.debug("Currently {} retries are scheduled.", retryFutures.size());
612 private void scheduleRetry(String io, String command, String params, String url, int retries) {
613 retryFutures.add(scheduler.schedule(() -> {
614 repeatSendCommandInternal(io, command, params, url, retries);
615 }, thingConfig.getRetryDelay(), TimeUnit.MILLISECONDS));
618 private String getThingLabelByURL(String io) {
619 Thing th = getThingByDeviceUrl(io);
621 if (th.getProperties().containsKey(NAME_STATE)) {
622 // Return label from Tahoma
623 return th.getProperties().get(NAME_STATE).replace("\"", "");
625 // Return label from OH2
626 String label = th.getLabel();
627 return label != null ? label.replace("\"", "") : "";
632 public @Nullable String getCurrentExecutions(String io) {
633 if (executions.containsKey(io)) {
634 return executions.get(io);
639 public void cancelExecution(String executionId) {
640 invokeCallToURL(DELETE_URL + executionId, "", HttpMethod.DELETE, null);
643 public void executeActionGroup(String id) {
644 if (ThingStatus.OFFLINE == thing.getStatus() && !reLogin()) {
647 String execId = executeActionGroupInternal(id);
648 if (execId == null) {
649 execId = executeActionGroupInternal(id);
651 if (execId != null) {
652 registerExecution(id, execId);
656 private boolean reLogin() {
657 logger.debug("Doing relogin");
658 reLoginNeeded = true;
660 return ThingStatus.OFFLINE != thing.getStatus();
663 public @Nullable String executeActionGroupInternal(String id) {
664 SomfyTahomaApplyResponse response = invokeCallToURL(EXEC_URL + id, "", HttpMethod.POST,
665 SomfyTahomaApplyResponse.class);
666 if (response != null) {
667 if (response.getExecId().isEmpty()) {
668 logger.debug("Got empty exec response");
671 return response.getExecId();
676 public void forceGatewaySync() {
677 invokeCallToURL(REFRESH_URL, "", HttpMethod.PUT, null);
680 public SomfyTahomaStatus getTahomaStatus(String gatewayId) {
681 SomfyTahomaStatusResponse data = invokeCallToURL(GATEWAYS_URL + gatewayId, "", HttpMethod.GET,
682 SomfyTahomaStatusResponse.class);
684 logger.debug("Tahoma status: {}", data.getConnectivity().getStatus());
685 logger.debug("Tahoma protocol version: {}", data.getConnectivity().getProtocolVersion());
686 return data.getConnectivity();
688 return new SomfyTahomaStatus();
691 private boolean isAuthenticationChallenge(Exception ex) {
692 return ex.getMessage().contains(AUTHENTICATION_CHALLENGE);
696 public void handleConfigurationUpdate(Map<String, Object> configurationParameters) {
697 super.handleConfigurationUpdate(configurationParameters);
698 if (configurationParameters.containsKey("email")) {
699 thingConfig.setEmail(configurationParameters.get("email").toString());
701 if (configurationParameters.containsKey("password")) {
702 thingConfig.setPassword(configurationParameters.get("password").toString());
706 public synchronized void refresh(String url, String stateName) {
707 SomfyTahomaState state = invokeCallToURL(DEVICES_URL + urlEncode(url) + "/states/" + stateName, "",
708 HttpMethod.GET, SomfyTahomaState.class);
709 if (state != null && !state.getName().isEmpty()) {
710 updateDevice(url, List.of(state));
714 private @Nullable <T> T invokeCallToURL(String url, String urlParameters, HttpMethod method,
715 @Nullable Class<T> classOfT) {
716 String response = "";
720 response = sendGetToTahomaWithCookie(url);
723 response = sendPutToTahomaWithCookie(url);
726 response = sendPostToTahomaWithCookie(url, urlParameters);
729 response = sendDeleteToTahomaWithCookie(url);
732 return classOfT != null ? gson.fromJson(response, classOfT) : null;
733 } catch (JsonSyntaxException e) {
734 logger.debug("Received data: {} is not JSON", response, e);
735 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Received invalid data");
736 } catch (ExecutionException e) {
737 if (isAuthenticationChallenge(e)) {
740 logger.debug("Cannot call url: {} with params: {}!", url, urlParameters, e);
741 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
743 } catch (InterruptedException | TimeoutException e) {
744 logger.debug("Cannot call url: {} with params: {}!", url, urlParameters, e);
745 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
746 if (e instanceof InterruptedException) {
747 Thread.currentThread().interrupt();