]> git.basschouten.com Git - openhab-addons.git/blob
03dd84d25c303d3aa3fcd1eddeaaf9f2bd040275
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2020 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.innogysmarthome.internal.handler;
14
15 import static org.openhab.binding.innogysmarthome.internal.InnogyBindingConstants.*;
16 import static org.openhab.binding.innogysmarthome.internal.client.Constants.API_URL_TOKEN;
17
18 import java.io.IOException;
19 import java.net.SocketTimeoutException;
20 import java.net.URI;
21 import java.time.format.DateTimeFormatter;
22 import java.time.format.FormatStyle;
23 import java.util.Collection;
24 import java.util.Collections;
25 import java.util.Map;
26 import java.util.Set;
27 import java.util.concurrent.*;
28
29 import org.apache.commons.lang.StringUtils;
30 import org.apache.commons.lang.exception.ExceptionUtils;
31 import org.eclipse.jdt.annotation.NonNullByDefault;
32 import org.eclipse.jdt.annotation.Nullable;
33 import org.eclipse.jetty.client.HttpClient;
34 import org.openhab.binding.innogysmarthome.internal.InnogyWebSocket;
35 import org.openhab.binding.innogysmarthome.internal.client.InnogyClient;
36 import org.openhab.binding.innogysmarthome.internal.client.entity.action.ShutterAction;
37 import org.openhab.binding.innogysmarthome.internal.client.entity.capability.Capability;
38 import org.openhab.binding.innogysmarthome.internal.client.entity.device.Device;
39 import org.openhab.binding.innogysmarthome.internal.client.entity.device.DeviceConfig;
40 import org.openhab.binding.innogysmarthome.internal.client.entity.event.BaseEvent;
41 import org.openhab.binding.innogysmarthome.internal.client.entity.event.Event;
42 import org.openhab.binding.innogysmarthome.internal.client.entity.event.MessageEvent;
43 import org.openhab.binding.innogysmarthome.internal.client.entity.link.Link;
44 import org.openhab.binding.innogysmarthome.internal.client.entity.message.Message;
45 import org.openhab.binding.innogysmarthome.internal.client.exception.ApiException;
46 import org.openhab.binding.innogysmarthome.internal.client.exception.AuthenticationException;
47 import org.openhab.binding.innogysmarthome.internal.client.exception.ControllerOfflineException;
48 import org.openhab.binding.innogysmarthome.internal.client.exception.InvalidActionTriggeredException;
49 import org.openhab.binding.innogysmarthome.internal.client.exception.RemoteAccessNotAllowedException;
50 import org.openhab.binding.innogysmarthome.internal.client.exception.SessionExistsException;
51 import org.openhab.binding.innogysmarthome.internal.discovery.InnogyDeviceDiscoveryService;
52 import org.openhab.binding.innogysmarthome.internal.listener.DeviceStatusListener;
53 import org.openhab.binding.innogysmarthome.internal.listener.EventListener;
54 import org.openhab.binding.innogysmarthome.internal.manager.DeviceStructureManager;
55 import org.openhab.core.auth.client.oauth2.AccessTokenRefreshListener;
56 import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
57 import org.openhab.core.auth.client.oauth2.OAuthClientService;
58 import org.openhab.core.auth.client.oauth2.OAuthException;
59 import org.openhab.core.auth.client.oauth2.OAuthFactory;
60 import org.openhab.core.auth.client.oauth2.OAuthResponseException;
61 import org.openhab.core.config.core.Configuration;
62 import org.openhab.core.library.types.DecimalType;
63 import org.openhab.core.thing.Bridge;
64 import org.openhab.core.thing.ChannelUID;
65 import org.openhab.core.thing.Thing;
66 import org.openhab.core.thing.ThingStatus;
67 import org.openhab.core.thing.ThingStatusDetail;
68 import org.openhab.core.thing.ThingTypeUID;
69 import org.openhab.core.thing.binding.BaseBridgeHandler;
70 import org.openhab.core.thing.binding.ThingHandlerService;
71 import org.openhab.core.types.Command;
72 import org.slf4j.Logger;
73 import org.slf4j.LoggerFactory;
74
75 import com.google.gson.Gson;
76
77 /**
78  * The {@link InnogyBridgeHandler} is responsible for handling the innogy SmartHome controller including the connection
79  * to the innogy backend for all communications with the innogy {@link Device}s.
80  * <p/>
81  * It implements the {@link AccessTokenRefreshListener} to handle updates of the oauth2 tokens and the
82  * {@link EventListener} to handle {@link Event}s, that are received by the {@link InnogyWebSocket}.
83  * <p/>
84  * The {@link Device}s are organized by the {@link DeviceStructureManager}, which is also responsible for the connection
85  * to the innogy SmartHome webservice via the {@link InnogyClient}.
86  *
87  * @author Oliver Kuhl - Initial contribution
88  * @author Hilbrand Bouwkamp - Refactored to use openHAB http and oauth2 libraries
89  */
90 @NonNullByDefault
91 public class InnogyBridgeHandler extends BaseBridgeHandler
92         implements AccessTokenRefreshListener, EventListener, DeviceStatusListener {
93
94     public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Collections.singleton(THING_TYPE_BRIDGE);
95
96     private final Logger logger = LoggerFactory.getLogger(InnogyBridgeHandler.class);
97     private final Gson gson = new Gson();
98     private final Object lock = new Object();
99     private final Set<DeviceStatusListener> deviceStatusListeners = new CopyOnWriteArraySet<>();
100     private final OAuthFactory oAuthFactory;
101     private final HttpClient httpClient;
102
103     private @Nullable InnogyClient client;
104     private @Nullable InnogyWebSocket webSocket;
105     private @Nullable DeviceStructureManager deviceStructMan;
106     private @Nullable String bridgeId;
107     private @Nullable ScheduledFuture<?> reinitJob;
108     private @NonNullByDefault({}) InnogyBridgeConfiguration bridgeConfiguration;
109     private @Nullable OAuthClientService oAuthService;
110
111     /**
112      * Constructs a new {@link InnogyBridgeHandler}.
113      *
114      * @param bridge Bridge thing to be used by this handler
115      * @param oAuthFactory Factory class to get OAuth2 service
116      * @param httpClient httpclient instance
117      */
118     public InnogyBridgeHandler(final Bridge bridge, final OAuthFactory oAuthFactory, final HttpClient httpClient) {
119         super(bridge);
120         this.oAuthFactory = oAuthFactory;
121         this.httpClient = httpClient;
122     }
123
124     @Override
125     public void handleCommand(final ChannelUID channelUID, final Command command) {
126         // not needed
127     }
128
129     @Override
130     public Collection<Class<? extends ThingHandlerService>> getServices() {
131         return Collections.singleton(InnogyDeviceDiscoveryService.class);
132     }
133
134     @Override
135     public void initialize() {
136         logger.debug("Initializing innogy SmartHome BridgeHandler...");
137         final InnogyBridgeConfiguration bridgeConfiguration = getConfigAs(InnogyBridgeConfiguration.class);
138         if (checkConfig(bridgeConfiguration)) {
139             this.bridgeConfiguration = bridgeConfiguration;
140             getScheduler().execute(this::initializeClient);
141         }
142     }
143
144     /**
145      * Checks bridge configuration. If configuration is valid returns true.
146      *
147      * @return true if the configuration if valid
148      */
149     private boolean checkConfig(final InnogyBridgeConfiguration bridgeConfiguration) {
150         if (BRAND_INNOGY_SMARTHOME.equals(bridgeConfiguration.brand)) {
151             return true;
152         } else {
153             logger.debug("Invalid brand '{}'. Make sure to select a brand in the SHC thing configuration!",
154                     bridgeConfiguration.brand);
155             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Invalid brand '"
156                     + bridgeConfiguration.brand + "'. Make sure to select a brand in the SHC thing configuration!");
157             return false;
158         }
159     }
160
161     /**
162      * Initializes the services and InnogyClient.
163      */
164     private void initializeClient() {
165         final OAuthClientService oAuthService = oAuthFactory.createOAuthClientService(thing.getUID().getAsString(),
166                 API_URL_TOKEN, API_URL_TOKEN, bridgeConfiguration.clientId, bridgeConfiguration.clientSecret, null,
167                 true);
168         this.oAuthService = oAuthService;
169
170         if (checkOnAuthCode()) {
171             final InnogyClient localClient = createInnogyClient(oAuthService, httpClient);
172             client = localClient;
173             deviceStructMan = new DeviceStructureManager(localClient);
174             oAuthService.addAccessTokenRefreshListener(this);
175             registerDeviceStatusListener(InnogyBridgeHandler.this);
176             scheduleRestartClient(false);
177         }
178     }
179
180     /**
181      * Fetches the OAuth2 tokens from innogy SmartHome service if the auth code is set in the configuration and if
182      * successful removes the auth code. Returns true if the auth code was not set or if the authcode was successfully
183      * used to get a new refresh and access token.
184      *
185      * @return true if success
186      */
187     private boolean checkOnAuthCode() {
188         if (StringUtils.isNotBlank(bridgeConfiguration.authcode)) {
189             logger.debug("Trying to get access and refresh tokens");
190             try {
191                 oAuthService.getAccessTokenResponseByAuthorizationCode(bridgeConfiguration.authcode,
192                         bridgeConfiguration.redirectUrl);
193                 final Configuration configuration = editConfiguration();
194                 configuration.put(CONFIG_AUTH_CODE, "");
195                 updateConfiguration(configuration);
196             } catch (IOException | OAuthException | OAuthResponseException e) {
197                 logger.debug("Error fetching access tokens. Invalid authcode! Please generate a new one. Detail: {}",
198                         e.getMessage());
199                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
200                         "Cannot connect to innogy SmartHome service. Please set auth-code!");
201                 return false;
202             }
203         }
204         return true;
205     }
206
207     /**
208      * Initializes the client and connects to the innogy SmartHome service via Client API. Based on the provided
209      * {@Link Configuration} while constructing {@Link InnogyClient}, the given oauth2 access and refresh tokens are
210      * used or - if not yet available - new tokens are fetched from the service using the provided auth code.
211      */
212     private void startClient() {
213         try {
214             logger.debug("Initializing innogy SmartHome client...");
215             final InnogyClient localClient = this.client;
216             if (localClient != null) {
217                 localClient.refreshStatus();
218             }
219         } catch (AuthenticationException | ApiException | IOException e) {
220             if (handleClientException(e)) {
221                 // If exception could not be handled properly it's no use to continue so we won't continue start
222                 logger.debug("Error initializing innogy SmartHome client.", e);
223                 return;
224             }
225         }
226         final DeviceStructureManager deviceStructMan = this.deviceStructMan;
227         if (deviceStructMan == null) {
228             return;
229         }
230         try {
231             deviceStructMan.refreshDevices();
232         } catch (IOException | ApiException | AuthenticationException e) {
233             if (handleClientException(e)) {
234                 // If exception could not be handled properly it's no use to continue so we won't continue start
235                 logger.debug("Error starting device structure manager.", e);
236                 return;
237             }
238         }
239
240         Device bridgeDevice = deviceStructMan.getBridgeDevice();
241         setBridgeProperties(bridgeDevice);
242         bridgeId = bridgeDevice.getId();
243         startWebsocket();
244     }
245
246     /**
247      * Start the websocket connection for receiving permanent update {@link Event}s from the innogy API.
248      */
249     private void startWebsocket() {
250         try {
251             InnogyWebSocket localWebSocket = createWebSocket();
252
253             if (this.webSocket != null && this.webSocket.isRunning()) {
254                 this.webSocket.stop();
255                 this.webSocket = null;
256             }
257
258             logger.debug("Starting innogy websocket.");
259             this.webSocket = localWebSocket;
260             localWebSocket.start();
261             updateStatus(ThingStatus.ONLINE);
262         } catch (final Exception e) { // Catch Exception because websocket start throws Exception
263             logger.warn("Error starting websocket.", e);
264             handleClientException(e);
265         }
266     }
267
268     InnogyWebSocket createWebSocket() throws IOException, AuthenticationException {
269         final AccessTokenResponse accessTokenResponse = client.getAccessTokenResponse();
270         final String webSocketUrl = WEBSOCKET_API_URL_EVENTS.replace("{token}", accessTokenResponse.getAccessToken());
271
272         logger.debug("WebSocket URL: {}...{}", webSocketUrl.substring(0, 70),
273                 webSocketUrl.substring(webSocketUrl.length() - 10));
274
275         return new InnogyWebSocket(this, URI.create(webSocketUrl), bridgeConfiguration.websocketidletimeout * 1000);
276     }
277
278     @Override
279     public void onAccessTokenResponse(final AccessTokenResponse credential) {
280         scheduleRestartClient(true);
281     }
282
283     /**
284      * Schedules a re-initialization in the given future.
285      *
286      * @param delayed when it is scheduled delayed, it starts with a delay of
287      *            {@link org.openhab.binding.innogysmarthome.internal.InnogyBindingConstants#REINITIALIZE_DELAY_SECONDS}
288      *            seconds,
289      *            otherwise it starts directly
290      */
291     private synchronized void scheduleRestartClient(final boolean delayed) {
292         @Nullable
293         final ScheduledFuture<?> localReinitJob = reinitJob;
294
295         if (localReinitJob != null && isAlreadyScheduled(localReinitJob)) {
296             logger.debug("Scheduling reinitialize - ignored: already triggered in {} seconds.",
297                     localReinitJob.getDelay(TimeUnit.SECONDS));
298             return;
299         }
300
301         final long seconds = delayed ? REINITIALIZE_DELAY_SECONDS : 0;
302         logger.debug("Scheduling reinitialize in {} seconds.", seconds);
303         reinitJob = getScheduler().schedule(this::startClient, seconds, TimeUnit.SECONDS);
304     }
305
306     private void setBridgeProperties(final Device bridgeDevice) {
307         final DeviceConfig config = bridgeDevice.getConfig();
308
309         logger.debug("Setting Bridge Device Properties for Bridge of type '{}' with ID '{}'", config.getName(),
310                 bridgeDevice.getId());
311         final Map<String, String> properties = editProperties();
312
313         setPropertyIfPresent(Thing.PROPERTY_VENDOR, bridgeDevice.getManufacturer(), properties);
314         setPropertyIfPresent(Thing.PROPERTY_SERIAL_NUMBER, bridgeDevice.getSerialnumber(), properties);
315         setPropertyIfPresent(PROPERTY_ID, bridgeDevice.getId(), properties);
316         setPropertyIfPresent(Thing.PROPERTY_FIRMWARE_VERSION, config.getFirmwareVersion(), properties);
317         setPropertyIfPresent(Thing.PROPERTY_HARDWARE_VERSION, config.getHardwareVersion(), properties);
318         setPropertyIfPresent(PROPERTY_SOFTWARE_VERSION, config.getSoftwareVersion(), properties);
319         setPropertyIfPresent(PROPERTY_IP_ADDRESS, config.getIPAddress(), properties);
320         setPropertyIfPresent(Thing.PROPERTY_MAC_ADDRESS, config.getMACAddress(), properties);
321         if (config.getRegistrationTime() != null) {
322             properties.put(PROPERTY_REGISTRATION_TIME,
323                     config.getRegistrationTime().format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)));
324         }
325         setPropertyIfPresent(PROPERTY_CONFIGURATION_STATE, config.getConfigurationState(), properties);
326         setPropertyIfPresent(PROPERTY_SHC_TYPE, bridgeDevice.getType(), properties);
327         setPropertyIfPresent(PROPERTY_TIME_ZONE, config.getTimeZone(), properties);
328         setPropertyIfPresent(PROPERTY_PROTOCOL_ID, config.getProtocolId(), properties);
329         setPropertyIfPresent(PROPERTY_GEOLOCATION, config.getGeoLocation(), properties);
330         setPropertyIfPresent(PROPERTY_CURRENT_UTC_OFFSET, config.getCurrentUTCOffset(), properties);
331         setPropertyIfPresent(PROPERTY_BACKEND_CONNECTION_MONITORED, config.getBackendConnectionMonitored(), properties);
332         setPropertyIfPresent(PROPERTY_RFCOM_FAILURE_NOTIFICATION, config.getRFCommFailureNotification(), properties);
333         updateProperties(properties);
334     }
335
336     private void setPropertyIfPresent(final String key, final @Nullable Object data,
337             final Map<String, String> properties) {
338         if (data != null) {
339             properties.put(key, data instanceof String ? (String) data : data.toString());
340         }
341     }
342
343     @Override
344     public void dispose() {
345         logger.debug("Disposing innogy SmartHome bridge handler '{}'", getThing().getUID().getId());
346         unregisterDeviceStatusListener(this);
347         cancelReinitJob();
348         if (webSocket != null) {
349             webSocket.stop();
350             webSocket = null;
351         }
352         client = null;
353         deviceStructMan = null;
354
355         super.dispose();
356         logger.debug("innogy SmartHome bridge handler shut down.");
357     }
358
359     private synchronized void cancelReinitJob() {
360         ScheduledFuture<?> reinitJob = this.reinitJob;
361
362         if (reinitJob != null) {
363             reinitJob.cancel(true);
364             this.reinitJob = null;
365         }
366     }
367
368     /**
369      * Registers a {@link DeviceStatusListener}.
370      *
371      * @param deviceStatusListener
372      * @return true, if successful
373      */
374     public boolean registerDeviceStatusListener(final DeviceStatusListener deviceStatusListener) {
375         return deviceStatusListeners.add(deviceStatusListener);
376     }
377
378     /**
379      * Unregisters a {@link DeviceStatusListener}.
380      *
381      * @param deviceStatusListener
382      * @return true, if successful
383      */
384     public boolean unregisterDeviceStatusListener(final DeviceStatusListener deviceStatusListener) {
385         return deviceStatusListeners.remove(deviceStatusListener);
386     }
387
388     /**
389      * Loads a Collection of {@link Device}s from the bridge and returns them.
390      *
391      * @return a Collection of {@link Device}s
392      */
393     public Collection<Device> loadDevices() {
394         final DeviceStructureManager deviceStructMan = this.deviceStructMan;
395         final Collection<Device> devices;
396
397         if (deviceStructMan == null) {
398             devices = Collections.emptyList();
399         } else {
400             devices = deviceStructMan.getDeviceList();
401         }
402         return devices;
403     }
404
405     /**
406      * Returns the {@link Device} with the given deviceId.
407      *
408      * @param deviceId
409      * @return {@link Device} or null, if it does not exist or no {@link DeviceStructureManager} is available
410      */
411     public @Nullable Device getDeviceById(final String deviceId) {
412         if (deviceStructMan != null) {
413             return deviceStructMan.getDeviceById(deviceId);
414         }
415         return null;
416     }
417
418     /**
419      * Refreshes the {@link Device} with the given id, by reloading the full device from the innogy webservice.
420      *
421      * @param deviceId
422      * @return the {@link Device} or null, if it does not exist or no {@link DeviceStructureManager} is available
423      */
424     public @Nullable Device refreshDevice(final String deviceId) {
425         final DeviceStructureManager deviceStructMan = this.deviceStructMan;
426         if (deviceStructMan == null) {
427             return null;
428         }
429
430         Device device = null;
431         try {
432             deviceStructMan.refreshDevice(deviceId);
433             device = deviceStructMan.getDeviceById(deviceId);
434         } catch (IOException | ApiException | AuthenticationException e) {
435             handleClientException(e);
436         }
437         return device;
438     }
439
440     @Override
441     public void onDeviceStateChanged(final Device device) {
442         synchronized (this.lock) {
443             if (!bridgeId.equals(device.getId())) {
444                 logger.trace("DeviceId {} not relevant for this handler (responsible for id {})", device.getId(),
445                         bridgeId);
446                 return;
447             }
448
449             logger.debug("onDeviceStateChanged called with device {}/{}", device.getConfig().getName(), device.getId());
450
451             // DEVICE STATES
452             if (device.hasDeviceState()) {
453                 final Double cpuUsage = device.getDeviceState().getState().getCpuUsage().getValue();
454                 if (cpuUsage != null) {
455                     logger.debug("-> CPU usage state: {}", cpuUsage);
456                     updateState(CHANNEL_CPU, new DecimalType(cpuUsage));
457                 }
458                 final Double diskUsage = device.getDeviceState().getState().getDiskUsage().getValue();
459                 if (diskUsage != null) {
460                     logger.debug("-> Disk usage state: {}", diskUsage);
461                     updateState(CHANNEL_DISK, new DecimalType(diskUsage));
462                 }
463                 final Double memoryUsage = device.getDeviceState().getState().getMemoryUsage().getValue();
464                 if (memoryUsage != null) {
465                     logger.debug("-> Memory usage state: {}", memoryUsage);
466                     updateState(CHANNEL_MEMORY, new DecimalType(memoryUsage));
467                 }
468
469             }
470
471         }
472     }
473
474     @Override
475     public void onDeviceStateChanged(final Device device, final Event event) {
476         synchronized (this.lock) {
477             if (!bridgeId.equals(device.getId())) {
478                 logger.trace("DeviceId {} not relevant for this handler (responsible for id {})", device.getId(),
479                         bridgeId);
480                 return;
481             }
482
483             logger.trace("DeviceId {} relevant for this handler.", device.getId());
484
485             if (event.isLinkedtoDevice() && DEVICE_SHCA.equals(device.getType())) {
486                 device.getDeviceState().getState().getCpuUsage().setValue(event.getProperties().getCpuUsage());
487                 device.getDeviceState().getState().getDiskUsage().setValue(event.getProperties().getDiskUsage());
488                 device.getDeviceState().getState().getMemoryUsage().setValue(event.getProperties().getMemoryUsage());
489                 onDeviceStateChanged(device);
490             }
491         }
492     }
493
494     @Override
495     public void onEvent(final String msg) {
496         logger.trace("onEvent called. Msg: {}", msg);
497
498         try {
499             final BaseEvent be = gson.fromJson(msg, BaseEvent.class);
500             logger.debug("Event no {} found. Type: {}", be.getSequenceNumber(), be.getType());
501             if (!BaseEvent.SUPPORTED_EVENT_TYPES.contains(be.getType())) {
502                 logger.debug("Event type {} not supported. Skipping...", be.getType());
503             } else {
504                 final Event event = gson.fromJson(msg, Event.class);
505
506                 switch (event.getType()) {
507                     case BaseEvent.TYPE_STATE_CHANGED:
508                     case BaseEvent.TYPE_BUTTON_PRESSED:
509                         handleStateChangedEvent(event);
510                         break;
511
512                     case BaseEvent.TYPE_DISCONNECT:
513                         logger.debug("Websocket disconnected.");
514                         scheduleRestartClient(true);
515                         break;
516
517                     case BaseEvent.TYPE_CONFIGURATION_CHANGED:
518                         if (client.getConfigVersion().equals(event.getConfigurationVersion().toString())) {
519                             logger.debug(
520                                     "Ignored configuration changed event with version '{}' as current version is '{}' the same.",
521                                     event.getConfigurationVersion(), client.getConfigVersion());
522                         } else {
523                             logger.info("Configuration changed from version {} to {}. Restarting innogy binding...",
524                                     client.getConfigVersion(), event.getConfigurationVersion());
525                             scheduleRestartClient(false);
526                         }
527                         break;
528
529                     case BaseEvent.TYPE_CONTROLLER_CONNECTIVITY_CHANGED:
530                         handleControllerConnectivityChangedEvent(event);
531                         break;
532
533                     case BaseEvent.TYPE_NEW_MESSAGE_RECEIVED:
534                     case BaseEvent.TYPE_MESSAGE_CREATED:
535                         final MessageEvent messageEvent = gson.fromJson(msg, MessageEvent.class);
536                         handleNewMessageReceivedEvent(messageEvent);
537                         break;
538
539                     case BaseEvent.TYPE_MESSAGE_DELETED:
540                         handleMessageDeletedEvent(event);
541                         break;
542
543                     default:
544                         logger.debug("Unsupported eventtype {}.", event.getType());
545                         break;
546                 }
547             }
548         } catch (IOException | ApiException | AuthenticationException | RuntimeException e) {
549             logger.debug("Error with Event: {}", e.getMessage(), e);
550             handleClientException(e);
551         }
552     }
553
554     @Override
555     public void onError(final Throwable cause) {
556         if (cause instanceof Exception) {
557             handleClientException((Exception) cause);
558         }
559     }
560
561     /**
562      * Handles the event that occurs, when the state of a device (like reachability) or a capability (like a temperature
563      * value) has changed.
564      *
565      * @param event
566      * @throws ApiException
567      * @throws IOException
568      * @throws AuthenticationException
569      */
570     public void handleStateChangedEvent(final Event event) throws ApiException, IOException, AuthenticationException {
571         final DeviceStructureManager deviceStructMan = this.deviceStructMan;
572         if (deviceStructMan == null) {
573             return;
574         }
575
576         // CAPABILITY
577         if (event.isLinkedtoCapability()) {
578             logger.trace("Event is linked to capability");
579             final Device device = deviceStructMan.getDeviceByCapabilityId(event.getSourceId());
580             if (device != null) {
581                 for (final DeviceStatusListener deviceStatusListener : deviceStatusListeners) {
582                     deviceStatusListener.onDeviceStateChanged(device, event);
583                 }
584             } else {
585                 logger.debug("Unknown/unsupported device for capability {}.", event.getSource());
586             }
587
588             // DEVICE
589         } else if (event.isLinkedtoDevice()) {
590             logger.trace("Event is linked to device");
591
592             if (!event.getSourceId().equals(deviceStructMan.getBridgeDevice().getId())) {
593                 deviceStructMan.refreshDevice(event.getSourceId());
594             }
595             final Device device = deviceStructMan.getDeviceById(event.getSourceId());
596             if (device != null) {
597                 for (final DeviceStatusListener deviceStatusListener : deviceStatusListeners) {
598                     deviceStatusListener.onDeviceStateChanged(device, event);
599                 }
600             } else {
601                 logger.debug("Unknown/unsupported device {}.", event.getSourceId());
602             }
603
604         } else {
605             logger.debug("link type {} not supported (yet?)", event.getSourceLinkType());
606         }
607     }
608
609     /**
610      * Handles the event that occurs, when the connectivity of the bridge has changed.
611      *
612      * @param event
613      * @throws ApiException
614      * @throws IOException
615      * @throws AuthenticationException
616      */
617     public void handleControllerConnectivityChangedEvent(final Event event)
618             throws ApiException, IOException, AuthenticationException {
619         final DeviceStructureManager deviceStructMan = this.deviceStructMan;
620         if (deviceStructMan == null) {
621             return;
622         }
623         final Boolean connected = event.getIsConnected();
624         if (connected != null) {
625             logger.debug("SmartHome Controller connectivity changed to {}.", connected ? "online" : "offline");
626             if (connected) {
627                 deviceStructMan.refreshDevices();
628                 updateStatus(ThingStatus.ONLINE);
629             } else {
630                 updateStatus(ThingStatus.OFFLINE);
631             }
632         } else {
633             logger.warn("isConnected property missing in event! (returned null)");
634         }
635     }
636
637     /**
638      * Handles the event that occurs, when a new message was received. Currently only handles low battery messages.
639      *
640      * @param event
641      * @throws ApiException
642      * @throws IOException
643      * @throws AuthenticationException
644      */
645     public void handleNewMessageReceivedEvent(final MessageEvent event)
646             throws ApiException, IOException, AuthenticationException {
647         final DeviceStructureManager deviceStructMan = this.deviceStructMan;
648         if (deviceStructMan == null) {
649             return;
650         }
651         final Message message = event.getMessage();
652         if (logger.isTraceEnabled()) {
653             logger.trace("Message: {}", gson.toJson(message));
654             logger.trace("Messagetype: {}", message.getType());
655         }
656         if (Message.TYPE_DEVICE_LOW_BATTERY.equals(message.getType()) && message.getDevices() != null) {
657             for (final String link : message.getDevices()) {
658                 deviceStructMan.refreshDevice(Link.getId(link));
659                 final Device device = deviceStructMan.getDeviceById(Link.getId(link));
660                 if (device != null) {
661                     for (final DeviceStatusListener deviceStatusListener : deviceStatusListeners) {
662                         deviceStatusListener.onDeviceStateChanged(device);
663                     }
664                 } else {
665                     logger.debug("Unknown/unsupported device {}.", event.getSourceId());
666                 }
667             }
668         } else {
669             logger.debug("Message received event not yet implemented for Messagetype {}.", message.getType());
670         }
671     }
672
673     /**
674      * Handle the event that occurs, when a message was deleted. In case of a low battery message this means, that the
675      * device is back to normal. Currently, only messages linked to devices are handled by refreshing the device data
676      * and informing the {@link InnogyDeviceHandler} about the changed device.
677      *
678      * @param event
679      * @throws ApiException
680      * @throws IOException
681      * @throws AuthenticationException
682      */
683     public void handleMessageDeletedEvent(final Event event) throws ApiException, IOException, AuthenticationException {
684         final DeviceStructureManager deviceStructMan = this.deviceStructMan;
685         if (deviceStructMan == null) {
686             return;
687         }
688         final String messageId = event.getData().getId();
689
690         logger.debug("handleMessageDeletedEvent with messageId '{}'", messageId);
691         Device device = deviceStructMan.getDeviceWithMessageId(messageId);
692
693         if (device != null) {
694             String id = device.getId();
695             deviceStructMan.refreshDevice(id);
696             device = deviceStructMan.getDeviceById(id);
697             if (device != null) {
698                 for (final DeviceStatusListener deviceStatusListener : deviceStatusListeners) {
699                     deviceStatusListener.onDeviceStateChanged(device);
700                 }
701             } else {
702                 logger.debug("No device with id {} found after refresh.", id);
703             }
704         } else {
705             logger.debug("No device found with message id {}.", messageId);
706         }
707     }
708
709     @Override
710     public void connectionClosed() {
711         scheduleRestartClient(true);
712     }
713
714     /**
715      * Sends the command to switch the {@link Device} with the given id to the new state. Is called by the
716      * {@link InnogyDeviceHandler} for switch devices like the VariableActuator, PSS, PSSO or ISS2.
717      *
718      * @param deviceId
719      * @param state
720      */
721     public void commandSwitchDevice(final String deviceId, final boolean state) {
722         final DeviceStructureManager deviceStructMan = this.deviceStructMan;
723         if (deviceStructMan == null) {
724             return;
725         }
726         try {
727             // VariableActuator
728             final String deviceType = deviceStructMan.getDeviceById(deviceId).getType();
729             if (DEVICE_VARIABLE_ACTUATOR.equals(deviceType)) {
730                 final String capabilityId = deviceStructMan.getCapabilityId(deviceId, Capability.TYPE_VARIABLEACTUATOR);
731                 if (capabilityId == null) {
732                     return;
733                 }
734                 client.setVariableActuatorState(capabilityId, state);
735
736                 // PSS / PSSO / ISS2
737             } else if (DEVICE_PSS.equals(deviceType) || DEVICE_PSSO.equals(deviceType)
738                     || DEVICE_ISS2.equals(deviceType)) {
739                 final String capabilityId = deviceStructMan.getCapabilityId(deviceId, Capability.TYPE_SWITCHACTUATOR);
740                 if (capabilityId == null) {
741                     return;
742                 }
743                 client.setSwitchActuatorState(capabilityId, state);
744             }
745         } catch (IOException | ApiException | AuthenticationException e) {
746             handleClientException(e);
747         }
748     }
749
750     /**
751      * Sends the command to update the point temperature of the {@link Device} with the given deviceId. Is called by the
752      * {@link InnogyDeviceHandler} for thermostat {@link Device}s like RST or WRT.
753      *
754      * @param deviceId
755      * @param pointTemperature
756      */
757     public void commandUpdatePointTemperature(final String deviceId, final double pointTemperature) {
758         final DeviceStructureManager deviceStructMan = this.deviceStructMan;
759         if (deviceStructMan == null) {
760             return;
761         }
762         try {
763             final String capabilityId = deviceStructMan.getCapabilityId(deviceId, Capability.TYPE_THERMOSTATACTUATOR);
764             if (capabilityId == null) {
765                 return;
766             }
767             client.setPointTemperatureState(capabilityId, pointTemperature);
768         } catch (IOException | ApiException | AuthenticationException e) {
769             handleClientException(e);
770         }
771     }
772
773     /**
774      * Sends the command to turn the alarm of the {@link Device} with the given id on or off. Is called by the
775      * {@link InnogyDeviceHandler} for smoke detector {@link Device}s like WSD or WSD2.
776      *
777      * @param deviceId
778      * @param alarmState
779      */
780     public void commandSwitchAlarm(final String deviceId, final boolean alarmState) {
781         final DeviceStructureManager deviceStructMan = this.deviceStructMan;
782         if (deviceStructMan == null) {
783             return;
784         }
785         try {
786             final String capabilityId = deviceStructMan.getCapabilityId(deviceId, Capability.TYPE_ALARMACTUATOR);
787             if (capabilityId == null) {
788                 return;
789             }
790             client.setAlarmActuatorState(capabilityId, alarmState);
791         } catch (IOException | ApiException | AuthenticationException e) {
792             handleClientException(e);
793         }
794     }
795
796     /**
797      * Sends the command to set the operation mode of the {@link Device} with the given deviceId to auto (or manual, if
798      * false). Is called by the {@link InnogyDeviceHandler} for thermostat {@link Device}s like RST.
799      *
800      * @param deviceId
801      * @param autoMode true activates the automatic mode, false the manual mode.
802      */
803     public void commandSetOperationMode(final String deviceId, final boolean autoMode) {
804         final DeviceStructureManager deviceStructMan = this.deviceStructMan;
805         if (deviceStructMan == null) {
806             return;
807         }
808         try {
809             final String capabilityId = deviceStructMan.getCapabilityId(deviceId, Capability.TYPE_THERMOSTATACTUATOR);
810             if (capabilityId == null) {
811                 return;
812             }
813             client.setOperationMode(capabilityId, autoMode);
814         } catch (IOException | ApiException | AuthenticationException e) {
815             handleClientException(e);
816         }
817     }
818
819     /**
820      * Sends the command to set the dimm level of the {@link Device} with the given id. Is called by the
821      * {@link InnogyDeviceHandler} for {@link Device}s like ISD2 or PSD.
822      *
823      * @param deviceId
824      * @param dimLevel
825      */
826     public void commandSetDimmLevel(final String deviceId, final int dimLevel) {
827         final DeviceStructureManager deviceStructMan = this.deviceStructMan;
828         if (deviceStructMan == null) {
829             return;
830         }
831         try {
832             final String capabilityId = deviceStructMan.getCapabilityId(deviceId, Capability.TYPE_DIMMERACTUATOR);
833             if (capabilityId == null) {
834                 return;
835             }
836             client.setDimmerActuatorState(capabilityId, dimLevel);
837         } catch (IOException | ApiException | AuthenticationException e) {
838             handleClientException(e);
839         }
840     }
841
842     /**
843      * Sends the command to set the rollershutter level of the {@link Device} with the given id. Is called by the
844      * {@link InnogyDeviceHandler} for {@link Device}s like ISR2.
845      *
846      * @param deviceId
847      * @param rollerSchutterLevel
848      */
849     public void commandSetRollerShutterLevel(final String deviceId, final int rollerSchutterLevel) {
850         final DeviceStructureManager deviceStructMan = this.deviceStructMan;
851         if (deviceStructMan == null) {
852             return;
853         }
854         try {
855             final String capabilityId = deviceStructMan.getCapabilityId(deviceId,
856                     Capability.TYPE_ROLLERSHUTTERACTUATOR);
857             if (capabilityId == null) {
858                 return;
859             }
860             client.setRollerShutterActuatorState(capabilityId, rollerSchutterLevel);
861         } catch (IOException | ApiException | AuthenticationException e) {
862             handleClientException(e);
863         }
864     }
865
866     /**
867      * Sends the command to start or stop moving the rollershutter (ISR2) in a specified direction
868      * 
869      * @param deviceId
870      * @param action
871      */
872     public void commandSetRollerShutterStop(final String deviceId, ShutterAction.ShutterActions action) {
873         final DeviceStructureManager deviceStructMan = this.deviceStructMan;
874         if (deviceStructMan == null) {
875             return;
876         }
877         try {
878             final String capabilityId = deviceStructMan.getCapabilityId(deviceId,
879                     Capability.TYPE_ROLLERSHUTTERACTUATOR);
880             if (capabilityId == null) {
881                 return;
882             }
883             client.setRollerShutterAction(capabilityId, action);
884         } catch (IOException | ApiException | AuthenticationException e) {
885             handleClientException(e);
886         }
887     }
888
889     ScheduledExecutorService getScheduler() {
890         return scheduler;
891     }
892
893     InnogyClient createInnogyClient(final OAuthClientService oAuthService, final HttpClient httpClient) {
894         return new InnogyClient(oAuthService, httpClient);
895     }
896
897     /**
898      * Handles all Exceptions of the client communication. For minor "errors" like an already existing session, it
899      * returns true to inform the binding to continue running. In other cases it may e.g. schedule a reinitialization of
900      * the binding.
901      *
902      * @param e the Exception
903      * @return boolean true, if binding should continue.
904      */
905     private boolean handleClientException(final Exception e) {
906         boolean isReinitialize = true;
907         if (e instanceof SessionExistsException) {
908             logger.debug("Session already exists. Continuing...");
909             isReinitialize = false;
910         } else if (e instanceof InvalidActionTriggeredException) {
911             logger.debug("Error triggering action: {}", e.getMessage());
912             isReinitialize = false;
913         } else if (e instanceof RemoteAccessNotAllowedException) {
914             // Remote access not allowed (usually by IP address change)
915             logger.debug("Remote access not allowed. Dropping access token and reinitializing binding...");
916             refreshAccessToken();
917         } else if (e instanceof ControllerOfflineException) {
918             logger.debug("innogy SmartHome Controller is offline.");
919             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE, e.getMessage());
920         } else if (e instanceof AuthenticationException) {
921             logger.debug("OAuthenticaton error, refreshing tokens: {}", e.getMessage());
922             refreshAccessToken();
923             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
924         } else if (e instanceof IOException) {
925             logger.debug("IO error: {}", e.getMessage());
926             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
927         } else if (e instanceof ApiException) {
928             logger.warn("Unexpected API error: {}", e.getMessage());
929             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
930         } else if (e instanceof TimeoutException) {
931             logger.debug("WebSocket timeout: {}", e.getMessage());
932         } else if (e instanceof SocketTimeoutException) {
933             logger.debug("Socket timeout: {}", e.getMessage());
934             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
935         } else if (e instanceof InterruptedException) {
936             isReinitialize = false;
937             Thread.currentThread().interrupt();
938         } else if (e instanceof ExecutionException) {
939             logger.debug("ExecutionException: {}", ExceptionUtils.getRootCauseMessage(e));
940             updateStatus(ThingStatus.OFFLINE);
941         } else {
942             logger.debug("Unknown exception", e);
943             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, e.getMessage());
944         }
945         if (isReinitialize) {
946             scheduleRestartClient(true);
947             return true;
948         }
949         return false;
950     }
951
952     private void refreshAccessToken() {
953         try {
954             final OAuthClientService localOAuthService = this.oAuthService;
955
956             if (localOAuthService != null) {
957                 oAuthService.refreshToken();
958             }
959         } catch (IOException | OAuthResponseException | OAuthException e) {
960             logger.debug("Could not refresh tokens", e);
961         }
962     }
963
964     /**
965      * Checks if the job is already (re-)scheduled.
966      * 
967      * @param job job to check
968      * @return true, when the job is already (re-)scheduled, otherwise false
969      */
970     private static boolean isAlreadyScheduled(ScheduledFuture<?> job) {
971         return job.getDelay(TimeUnit.SECONDS) > 0;
972     }
973 }