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.ExecutionException;
22 import java.util.concurrent.ScheduledFuture;
23 import java.util.concurrent.TimeUnit;
24 import java.util.concurrent.TimeoutException;
26 import org.apache.commons.lang.StringUtils;
27 import org.eclipse.jdt.annotation.NonNullByDefault;
28 import org.eclipse.jdt.annotation.Nullable;
29 import org.eclipse.jetty.client.HttpClient;
30 import org.eclipse.jetty.client.api.ContentResponse;
31 import org.eclipse.jetty.client.api.Request;
32 import org.eclipse.jetty.client.util.StringContentProvider;
33 import org.eclipse.jetty.http.HttpHeader;
34 import org.eclipse.jetty.http.HttpMethod;
35 import org.openhab.binding.somfytahoma.internal.config.SomfyTahomaConfig;
36 import org.openhab.binding.somfytahoma.internal.discovery.SomfyTahomaItemDiscoveryService;
37 import org.openhab.binding.somfytahoma.internal.model.*;
38 import org.openhab.core.io.net.http.HttpClientFactory;
39 import org.openhab.core.thing.*;
40 import org.openhab.core.thing.binding.BaseBridgeHandler;
41 import org.openhab.core.thing.binding.ThingHandlerService;
42 import org.openhab.core.types.Command;
43 import org.slf4j.Logger;
44 import org.slf4j.LoggerFactory;
46 import com.google.gson.Gson;
47 import com.google.gson.JsonElement;
48 import com.google.gson.JsonSyntaxException;
51 * The {@link SomfyTahomaBridgeHandler} is responsible for handling commands, which are
52 * sent to one of the channels.
54 * @author Ondrej Pecta - Initial contribution
57 public class SomfyTahomaBridgeHandler extends BaseBridgeHandler {
59 private final Logger logger = LoggerFactory.getLogger(SomfyTahomaBridgeHandler.class);
62 * The shared HttpClient
64 private final HttpClient httpClient;
67 * Future to poll for updates
69 private @Nullable ScheduledFuture<?> pollFuture;
72 * Future to poll for status
74 private @Nullable ScheduledFuture<?> statusFuture;
77 * Future to set reconciliation flag
79 private @Nullable ScheduledFuture<?> reconciliationFuture;
84 private Map<String, String> executions = new HashMap<>();
86 // Too many requests flag
87 private boolean tooManyRequests = false;
89 // Silent relogin flag
90 private boolean reLoginNeeded = false;
92 // Reconciliation flag
93 private boolean reconciliation = false;
98 protected SomfyTahomaConfig thingConfig = new SomfyTahomaConfig();
101 * Id of registered events
103 private String eventsId = "";
106 private final Gson gson = new Gson();
108 public SomfyTahomaBridgeHandler(Bridge thing, HttpClientFactory httpClientFactory) {
110 this.httpClient = httpClientFactory.createHttpClient("somfy_" + thing.getUID().getId());
114 public void handleCommand(ChannelUID channelUID, Command command) {
118 public void initialize() {
119 thingConfig = getConfigAs(SomfyTahomaConfig.class);
123 } catch (Exception e) {
124 logger.debug("Cannot start http client for: {}", thing.getBridgeUID().getId(), e);
128 scheduler.execute(() -> {
131 logger.debug("Initialize done...");
136 * starts this things polling future
138 private void initPolling() {
140 pollFuture = scheduler.scheduleWithFixedDelay(() -> {
142 }, 10, thingConfig.getRefresh(), TimeUnit.SECONDS);
144 statusFuture = scheduler.scheduleWithFixedDelay(() -> {
145 refreshTahomaStates();
146 }, 60, thingConfig.getStatusTimeout(), TimeUnit.SECONDS);
148 reconciliationFuture = scheduler.scheduleWithFixedDelay(() -> {
149 enableReconciliation();
150 }, RECONCILIATION_TIME, RECONCILIATION_TIME, TimeUnit.SECONDS);
153 public synchronized void login() {
156 if (StringUtils.isEmpty(thingConfig.getEmail()) || StringUtils.isEmpty(thingConfig.getPassword())) {
157 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
158 "Can not access device as username and/or password are null");
162 if (tooManyRequests) {
163 logger.debug("Skipping login due to too many requests");
167 if (ThingStatus.ONLINE == thing.getStatus() && !reLoginNeeded) {
168 logger.debug("No need to log in, because already logged in");
172 reLoginNeeded = false;
175 url = TAHOMA_API_URL + "login";
176 String urlParameters = "userId=" + urlEncode(thingConfig.getEmail()) + "&userPassword="
177 + urlEncode(thingConfig.getPassword());
179 ContentResponse response = sendRequestBuilder(url, HttpMethod.POST)
180 .content(new StringContentProvider(urlParameters),
181 "application/x-www-form-urlencoded; charset=UTF-8")
184 if (logger.isTraceEnabled()) {
185 logger.trace("Login response: {}", response.getContentAsString());
188 SomfyTahomaLoginResponse data = gson.fromJson(response.getContentAsString(),
189 SomfyTahomaLoginResponse.class);
190 if (data.isSuccess()) {
191 logger.debug("SomfyTahoma version: {}", data.getVersion());
192 String id = registerEvents();
193 if (id != null && !id.equals(UNAUTHORIZED)) {
195 logger.debug("Events id: {}", eventsId);
196 updateStatus(ThingStatus.ONLINE);
198 logger.debug("Events id error: {}", id);
201 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
202 "Error logging in: " + data.getError());
203 if (data.getError().startsWith(TOO_MANY_REQUESTS)) {
204 setTooManyRequests();
207 } catch (JsonSyntaxException e) {
208 logger.debug("Received invalid data", e);
209 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Received invalid data");
210 } catch (InterruptedException | ExecutionException | TimeoutException e) {
211 if (e instanceof ExecutionException) {
212 if (e.getMessage().contains(AUTHENTICATION_CHALLENGE)) {
213 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
214 "Authentication challenge");
215 setTooManyRequests();
219 logger.debug("Cannot get login cookie!", e);
220 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Cannot get login cookie");
221 if (e instanceof InterruptedException) {
222 Thread.currentThread().interrupt();
227 private void setTooManyRequests() {
228 logger.debug("Too many requests error, suspending activity for {} seconds", SUSPEND_TIME);
229 tooManyRequests = true;
230 scheduler.schedule(this::enableLogin, SUSPEND_TIME, TimeUnit.SECONDS);
233 private @Nullable String registerEvents() {
234 SomfyTahomaRegisterEventsResponse response = invokeCallToURL(TAHOMA_EVENTS_URL + "register", "",
235 HttpMethod.POST, SomfyTahomaRegisterEventsResponse.class);
236 return response != null ? response.getId() : null;
239 private String urlEncode(String text) {
241 return URLEncoder.encode(text, StandardCharsets.UTF_8.toString());
242 } catch (UnsupportedEncodingException e) {
247 private void enableLogin() {
248 tooManyRequests = false;
251 private List<SomfyTahomaEvent> getEvents() {
252 SomfyTahomaEvent[] response = invokeCallToURL(TAHOMA_API_URL + "events/" + eventsId + "/fetch", "",
253 HttpMethod.POST, SomfyTahomaEvent[].class);
254 return response != null ? List.of(response) : List.of();
258 public void handleRemoval() {
259 super.handleRemoval();
264 public Collection<Class<? extends ThingHandlerService>> getServices() {
265 return Collections.singleton(SomfyTahomaItemDiscoveryService.class);
269 public void dispose() {
274 private void cleanup() {
275 logger.debug("Doing cleanup");
280 } catch (Exception e) {
281 logger.debug("Error during http client stopping", e);
286 public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
287 super.bridgeStatusChanged(bridgeStatusInfo);
288 if (ThingStatus.UNINITIALIZED == bridgeStatusInfo.getStatus()) {
294 * Stops this thing's polling future
296 private void stopPolling() {
297 ScheduledFuture<?> localPollFuture = pollFuture;
298 if (localPollFuture != null && !localPollFuture.isCancelled()) {
299 localPollFuture.cancel(true);
301 ScheduledFuture<?> localStatusFuture = statusFuture;
302 if (localStatusFuture != null && !localStatusFuture.isCancelled()) {
303 localStatusFuture.cancel(true);
305 ScheduledFuture<?> localReconciliationFuture = reconciliationFuture;
306 if (localReconciliationFuture != null && !localReconciliationFuture.isCancelled()) {
307 localReconciliationFuture.cancel(true);
311 public List<SomfyTahomaActionGroup> listActionGroups() {
312 SomfyTahomaActionGroup[] list = invokeCallToURL(TAHOMA_API_URL + "actionGroups", "", HttpMethod.GET,
313 SomfyTahomaActionGroup[].class);
314 return list != null ? List.of(list) : List.of();
317 public @Nullable SomfyTahomaSetup getSetup() {
318 return invokeCallToURL(TAHOMA_API_URL + "setup", "", HttpMethod.GET, SomfyTahomaSetup.class);
321 public List<SomfyTahomaDevice> getDevices() {
322 SomfyTahomaDevice[] response = invokeCallToURL(SETUP_URL + "devices", "", HttpMethod.GET,
323 SomfyTahomaDevice[].class);
324 return response != null ? List.of(response) : List.of();
327 private void getTahomaUpdates() {
328 logger.debug("Getting Tahoma Updates...");
329 if (ThingStatus.OFFLINE == thing.getStatus() && !reLogin()) {
333 List<SomfyTahomaEvent> events = getEvents();
334 logger.trace("Got total of {} events", events.size());
335 for (SomfyTahomaEvent event : events) {
340 private void processEvent(SomfyTahomaEvent event) {
341 logger.debug("Got event: {}", event.getName());
342 switch (event.getName()) {
343 case "ExecutionRegisteredEvent":
344 processExecutionRegisteredEvent(event);
346 case "ExecutionStateChangedEvent":
347 processExecutionChangedEvent(event);
349 case "DeviceStateChangedEvent":
350 processStateChangedEvent(event);
352 case "RefreshAllDevicesStatesCompletedEvent":
353 scheduler.schedule(this::updateThings, 1, TimeUnit.SECONDS);
355 case "GatewayAliveEvent":
356 case "GatewayDownEvent":
357 processGatewayEvent(event);
360 // ignore other states
364 private synchronized void updateThings() {
365 boolean needsUpdate = reconciliation;
367 for (Thing th : getThing().getThings()) {
368 if (ThingStatus.ONLINE != th.getStatus()) {
373 // update all states only if necessary
376 reconciliation = false;
380 private void processExecutionRegisteredEvent(SomfyTahomaEvent event) {
381 JsonElement el = event.getAction();
382 if (el.isJsonArray()) {
383 SomfyTahomaAction[] actions = gson.fromJson(el, SomfyTahomaAction[].class);
384 for (SomfyTahomaAction action : actions) {
385 registerExecution(action.getDeviceURL(), event.getExecId());
388 SomfyTahomaAction action = gson.fromJson(el, SomfyTahomaAction.class);
389 registerExecution(action.getDeviceURL(), event.getExecId());
393 private void processExecutionChangedEvent(SomfyTahomaEvent event) {
394 if (FAILED_EVENT.equals(event.getNewState()) || COMPLETED_EVENT.equals(event.getNewState())) {
395 logger.debug("Removing execution id: {}", event.getExecId());
396 unregisterExecution(event.getExecId());
400 private void registerExecution(String url, String execId) {
401 if (executions.containsKey(url)) {
402 executions.remove(url);
403 logger.debug("Previous execution exists for url: {}", url);
405 executions.put(url, execId);
408 private void unregisterExecution(String execId) {
409 if (executions.containsValue(execId)) {
410 executions.values().removeAll(Collections.singleton(execId));
412 logger.debug("Cannot remove execution id: {}, because it is not registered", execId);
416 private void processGatewayEvent(SomfyTahomaEvent event) {
417 // update gateway status
418 for (Thing th : getThing().getThings()) {
419 if (THING_TYPE_GATEWAY.equals(th.getThingTypeUID())) {
420 SomfyTahomaGatewayHandler gatewayHandler = (SomfyTahomaGatewayHandler) th.getHandler();
421 if (gatewayHandler != null && gatewayHandler.getGateWayId().equals(event.getGatewayId())) {
422 gatewayHandler.refresh(STATUS);
428 private synchronized void updateAllStates() {
429 logger.debug("Updating all states");
430 getDevices().forEach(device -> updateDevice(device));
433 private void updateDevice(SomfyTahomaDevice device) {
434 String url = device.getDeviceURL();
435 List<SomfyTahomaState> states = device.getStates();
436 updateDevice(url, states);
439 private void updateDevice(String url, List<SomfyTahomaState> states) {
440 Thing th = getThingByDeviceUrl(url);
444 SomfyTahomaBaseThingHandler handler = (SomfyTahomaBaseThingHandler) th.getHandler();
445 if (handler != null) {
446 handler.updateThingStatus(states);
447 handler.updateThingChannels(states);
451 private void processStateChangedEvent(SomfyTahomaEvent event) {
452 String deviceUrl = event.getDeviceUrl();
453 List<SomfyTahomaState> states = event.getDeviceStates();
454 logger.debug("States for device {} : {}", deviceUrl, states);
455 Thing thing = getThingByDeviceUrl(deviceUrl);
458 logger.debug("Updating status of thing: {}", thing.getLabel());
459 SomfyTahomaBaseThingHandler handler = (SomfyTahomaBaseThingHandler) thing.getHandler();
461 if (handler != null) {
462 // update thing status
463 handler.updateThingStatus(states);
464 handler.updateThingChannels(states);
467 logger.debug("Thing handler is null, probably not bound thing.");
471 private void enableReconciliation() {
472 logger.debug("Enabling reconciliation");
473 reconciliation = true;
476 private void refreshTahomaStates() {
477 logger.debug("Refreshing Tahoma states...");
478 if (ThingStatus.OFFLINE == thing.getStatus() && !reLogin()) {
482 // force Tahoma to ask for actual states
486 private @Nullable Thing getThingByDeviceUrl(String deviceUrl) {
487 for (Thing th : getThing().getThings()) {
488 String url = (String) th.getConfiguration().get("url");
489 if (deviceUrl.equals(url)) {
496 private void logout() {
499 sendGetToTahomaWithCookie(TAHOMA_API_URL + "logout");
500 } catch (InterruptedException | ExecutionException | TimeoutException e) {
501 logger.debug("Cannot send logout command!", e);
502 if (e instanceof InterruptedException) {
503 Thread.currentThread().interrupt();
508 private String sendPostToTahomaWithCookie(String url, String urlParameters)
509 throws InterruptedException, ExecutionException, TimeoutException {
510 return sendMethodToTahomaWithCookie(url, HttpMethod.POST, urlParameters);
513 private String sendGetToTahomaWithCookie(String url)
514 throws InterruptedException, ExecutionException, TimeoutException {
515 return sendMethodToTahomaWithCookie(url, HttpMethod.GET);
518 private String sendPutToTahomaWithCookie(String url)
519 throws InterruptedException, ExecutionException, TimeoutException {
520 return sendMethodToTahomaWithCookie(url, HttpMethod.PUT);
523 private String sendDeleteToTahomaWithCookie(String url)
524 throws InterruptedException, ExecutionException, TimeoutException {
525 return sendMethodToTahomaWithCookie(url, HttpMethod.DELETE);
528 private String sendMethodToTahomaWithCookie(String url, HttpMethod method)
529 throws InterruptedException, ExecutionException, TimeoutException {
530 return sendMethodToTahomaWithCookie(url, method, "");
533 private String sendMethodToTahomaWithCookie(String url, HttpMethod method, String urlParameters)
534 throws InterruptedException, ExecutionException, TimeoutException {
535 logger.trace("Sending {} to url: {} with data: {}", method.asString(), url, urlParameters);
536 Request request = sendRequestBuilder(url, method);
537 if (StringUtils.isNotEmpty(urlParameters)) {
538 request = request.content(new StringContentProvider(urlParameters), "application/json;charset=UTF-8");
541 ContentResponse response = request.send();
543 if (logger.isTraceEnabled()) {
544 logger.trace("Response: {}", response.getContentAsString());
547 if (response.getStatus() < 200 || response.getStatus() >= 300) {
548 logger.debug("Received unexpected status code: {}", response.getStatus());
550 return response.getContentAsString();
553 private Request sendRequestBuilder(String url, HttpMethod method) {
554 return httpClient.newRequest(url).method(method).header(HttpHeader.ACCEPT_LANGUAGE, "en-US,en")
555 .header(HttpHeader.ACCEPT_ENCODING, "gzip, deflate").header("X-Requested-With", "XMLHttpRequest")
556 .timeout(TAHOMA_TIMEOUT, TimeUnit.SECONDS).agent(TAHOMA_AGENT);
559 public void sendCommand(String io, String command, String params, String url) {
560 if (ThingStatus.OFFLINE == thing.getStatus() && !reLogin()) {
564 Boolean result = sendCommandInternal(io, command, params, url);
566 sendCommandInternal(io, command, params, url);
570 private Boolean sendCommandInternal(String io, String command, String params, String url) {
571 String value = params.equals("[]") ? command : params.replace("\"", "");
572 String urlParameters = "{\"label\":\"" + getThingLabelByURL(io) + " - " + value
573 + " - OH2\",\"actions\":[{\"deviceURL\":\"" + io + "\",\"commands\":[{\"name\":\"" + command
574 + "\",\"parameters\":" + params + "}]}]}";
575 SomfyTahomaApplyResponse response = invokeCallToURL(url, urlParameters, HttpMethod.POST,
576 SomfyTahomaApplyResponse.class);
577 if (response != null) {
578 if (!response.getExecId().isEmpty()) {
579 logger.debug("Exec id: {}", response.getExecId());
580 registerExecution(io, response.getExecId());
582 logger.debug("ExecId is empty!");
590 private String getThingLabelByURL(String io) {
591 Thing th = getThingByDeviceUrl(io);
593 if (th.getProperties().containsKey(NAME_STATE)) {
594 // Return label from Tahoma
595 return th.getProperties().get(NAME_STATE).replace("\"", "");
597 // Return label from OH2
598 String label = th.getLabel();
599 return label != null ? label.replace("\"", "") : "";
604 public @Nullable String getCurrentExecutions(String io) {
605 if (executions.containsKey(io)) {
606 return executions.get(io);
611 public void cancelExecution(String executionId) {
612 invokeCallToURL(DELETE_URL + executionId, "", HttpMethod.DELETE, null);
615 public void executeActionGroup(String id) {
616 if (ThingStatus.OFFLINE == thing.getStatus() && !reLogin()) {
619 String execId = executeActionGroupInternal(id);
620 if (execId == null) {
621 execId = executeActionGroupInternal(id);
623 if (execId != null) {
624 registerExecution(id, execId);
628 private boolean reLogin() {
629 logger.debug("Doing relogin");
630 reLoginNeeded = true;
632 return ThingStatus.OFFLINE != thing.getStatus();
635 public @Nullable String executeActionGroupInternal(String id) {
636 SomfyTahomaApplyResponse response = invokeCallToURL(EXEC_URL + id, "", HttpMethod.POST,
637 SomfyTahomaApplyResponse.class);
638 if (response != null) {
639 if (response.getExecId().isEmpty()) {
640 logger.debug("Got empty exec response");
643 return response.getExecId();
648 public void forceGatewaySync() {
649 invokeCallToURL(REFRESH_URL, "", HttpMethod.PUT, null);
652 public SomfyTahomaStatus getTahomaStatus(String gatewayId) {
653 SomfyTahomaStatusResponse data = invokeCallToURL(GATEWAYS_URL + gatewayId, "", HttpMethod.GET,
654 SomfyTahomaStatusResponse.class);
656 logger.debug("Tahoma status: {}", data.getConnectivity().getStatus());
657 logger.debug("Tahoma protocol version: {}", data.getConnectivity().getProtocolVersion());
658 return data.getConnectivity();
660 return new SomfyTahomaStatus();
663 private boolean isAuthenticationChallenge(Exception ex) {
664 return ex.getMessage().contains(AUTHENTICATION_CHALLENGE);
668 public void handleConfigurationUpdate(Map<String, Object> configurationParameters) {
669 super.handleConfigurationUpdate(configurationParameters);
670 if (configurationParameters.containsKey("email")) {
671 thingConfig.setEmail(configurationParameters.get("email").toString());
673 if (configurationParameters.containsKey("password")) {
674 thingConfig.setPassword(configurationParameters.get("password").toString());
678 public synchronized void refresh(String url, String stateName) {
679 SomfyTahomaState state = invokeCallToURL(DEVICES_URL + urlEncode(url) + "/states/" + stateName, "",
680 HttpMethod.GET, SomfyTahomaState.class);
681 if (state != null && !state.getName().isEmpty()) {
682 updateDevice(url, List.of(state));
686 private @Nullable <T> T invokeCallToURL(String url, String urlParameters, HttpMethod method,
687 @Nullable Class<T> classOfT) {
688 String response = "";
692 response = sendGetToTahomaWithCookie(url);
695 response = sendPutToTahomaWithCookie(url);
698 response = sendPostToTahomaWithCookie(url, urlParameters);
701 response = sendDeleteToTahomaWithCookie(url);
704 return classOfT != null ? gson.fromJson(response, classOfT) : null;
705 } catch (JsonSyntaxException e) {
706 logger.debug("Received data: {} is not JSON", response, e);
707 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Received invalid data");
708 } catch (ExecutionException e) {
709 if (isAuthenticationChallenge(e)) {
712 logger.debug("Cannot call url: {} with params: {}!", url, urlParameters, e);
713 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
715 } catch (InterruptedException | TimeoutException e) {
716 logger.debug("Cannot call url: {} with params: {}!", url, urlParameters, e);
717 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
718 if (e instanceof InterruptedException) {
719 Thread.currentThread().interrupt();