]> git.basschouten.com Git - openhab-addons.git/blob
d08b89218018a404f3dc9960ba5354acd5f3337b
[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.hue.internal.handler;
14
15 import static org.openhab.binding.hue.internal.HueBindingConstants.*;
16 import static org.openhab.core.thing.Thing.*;
17
18 import java.io.IOException;
19 import java.util.ArrayList;
20 import java.util.Collection;
21 import java.util.Collections;
22 import java.util.HashMap;
23 import java.util.List;
24 import java.util.Map;
25 import java.util.Set;
26 import java.util.concurrent.Callable;
27 import java.util.concurrent.ConcurrentHashMap;
28 import java.util.concurrent.Future;
29 import java.util.concurrent.ScheduledFuture;
30 import java.util.concurrent.TimeUnit;
31 import java.util.concurrent.locks.ReentrantLock;
32 import java.util.stream.Collectors;
33
34 import org.eclipse.jdt.annotation.NonNullByDefault;
35 import org.eclipse.jdt.annotation.Nullable;
36 import org.openhab.binding.hue.internal.ApiVersionUtils;
37 import org.openhab.binding.hue.internal.Config;
38 import org.openhab.binding.hue.internal.ConfigUpdate;
39 import org.openhab.binding.hue.internal.FullConfig;
40 import org.openhab.binding.hue.internal.FullGroup;
41 import org.openhab.binding.hue.internal.FullLight;
42 import org.openhab.binding.hue.internal.FullSensor;
43 import org.openhab.binding.hue.internal.HueBridge;
44 import org.openhab.binding.hue.internal.HueConfigStatusMessage;
45 import org.openhab.binding.hue.internal.Scene;
46 import org.openhab.binding.hue.internal.State;
47 import org.openhab.binding.hue.internal.StateUpdate;
48 import org.openhab.binding.hue.internal.config.HueBridgeConfig;
49 import org.openhab.binding.hue.internal.discovery.HueLightDiscoveryService;
50 import org.openhab.binding.hue.internal.exceptions.ApiException;
51 import org.openhab.binding.hue.internal.exceptions.DeviceOffException;
52 import org.openhab.binding.hue.internal.exceptions.EntityNotAvailableException;
53 import org.openhab.binding.hue.internal.exceptions.LinkButtonException;
54 import org.openhab.binding.hue.internal.exceptions.UnauthorizedException;
55 import org.openhab.core.config.core.Configuration;
56 import org.openhab.core.config.core.status.ConfigStatusMessage;
57 import org.openhab.core.library.types.HSBType;
58 import org.openhab.core.library.types.OnOffType;
59 import org.openhab.core.library.types.StringType;
60 import org.openhab.core.thing.Bridge;
61 import org.openhab.core.thing.ChannelUID;
62 import org.openhab.core.thing.ThingStatus;
63 import org.openhab.core.thing.ThingStatusDetail;
64 import org.openhab.core.thing.ThingTypeUID;
65 import org.openhab.core.thing.binding.ConfigStatusBridgeHandler;
66 import org.openhab.core.types.Command;
67 import org.openhab.core.types.StateOption;
68 import org.slf4j.Logger;
69 import org.slf4j.LoggerFactory;
70
71 /**
72  * {@link HueBridgeHandler} is the handler for a hue bridge and connects it to
73  * the framework. All {@link HueLightHandler}s use the {@link HueBridgeHandler} to execute the actual commands.
74  *
75  * @author Dennis Nobel - Initial contribution
76  * @author Oliver Libutzki - Adjustments
77  * @author Kai Kreuzer - improved state handling
78  * @author Andre Fuechsel - implemented getFullLights(), startSearch()
79  * @author Thomas Höfer - added thing properties
80  * @author Stefan Bußweiler - Added new thing status handling
81  * @author Jochen Hiller - fixed status updates, use reachable=true/false for state compare
82  * @author Denis Dudnik - switched to internally integrated source of Jue library
83  * @author Samuel Leisering - Added support for sensor API
84  * @author Christoph Weitkamp - Added support for sensor API
85  * @author Laurent Garnier - Added support for groups
86  */
87 @NonNullByDefault
88 public class HueBridgeHandler extends ConfigStatusBridgeHandler implements HueClient {
89
90     public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Collections.singleton(THING_TYPE_BRIDGE);
91
92     private static final long BYPASS_MIN_DURATION_BEFORE_CMD = 1500L;
93
94     private static final String DEVICE_TYPE = "EclipseSmartHome";
95
96     private static final long SCENE_POLLING_INTERVAL = TimeUnit.SECONDS.convert(10, TimeUnit.MINUTES);
97
98     private final Logger logger = LoggerFactory.getLogger(HueBridgeHandler.class);
99     private final HueStateDescriptionOptionProvider stateDescriptionOptionProvider;
100
101     private final Map<String, @Nullable FullLight> lastLightStates = new ConcurrentHashMap<>();
102     private final Map<String, @Nullable FullSensor> lastSensorStates = new ConcurrentHashMap<>();
103     private final Map<String, @Nullable FullGroup> lastGroupStates = new ConcurrentHashMap<>();
104
105     private @Nullable HueLightDiscoveryService discoveryService;
106     private final Map<String, @Nullable LightStatusListener> lightStatusListeners = new ConcurrentHashMap<>();
107     private final Map<String, @Nullable SensorStatusListener> sensorStatusListeners = new ConcurrentHashMap<>();
108     private final Map<String, @Nullable GroupStatusListener> groupStatusListeners = new ConcurrentHashMap<>();
109
110     final ReentrantLock pollingLock = new ReentrantLock();
111
112     abstract class PollingRunnable implements Runnable {
113         @Override
114         public void run() {
115             try {
116                 pollingLock.lock();
117                 if (!lastBridgeConnectionState) {
118                     // if user is not set in configuration try to create a new user on Hue bridge
119                     if (hueBridgeConfig.getUserName() == null) {
120                         hueBridge.getFullConfig();
121                     }
122                     lastBridgeConnectionState = tryResumeBridgeConnection();
123                 }
124                 if (lastBridgeConnectionState) {
125                     doConnectedRun();
126                     if (thing.getStatus() != ThingStatus.ONLINE) {
127                         updateStatus(ThingStatus.ONLINE);
128                     }
129                 }
130             } catch (UnauthorizedException | IllegalStateException e) {
131                 if (isReachable(hueBridge.getIPAddress())) {
132                     lastBridgeConnectionState = false;
133                     if (onNotAuthenticated()) {
134                         updateStatus(ThingStatus.ONLINE);
135                     }
136                 } else if (lastBridgeConnectionState || thing.getStatus() == ThingStatus.INITIALIZING) {
137                     lastBridgeConnectionState = false;
138                     onConnectionLost();
139                 }
140             } catch (ApiException | IOException e) {
141                 if (hueBridge != null && lastBridgeConnectionState) {
142                     logger.debug("Connection to Hue Bridge {} lost.", hueBridge.getIPAddress());
143                     lastBridgeConnectionState = false;
144                     onConnectionLost();
145                 }
146             } catch (RuntimeException e) {
147                 logger.warn("An unexpected error occurred: {}", e.getMessage(), e);
148                 lastBridgeConnectionState = false;
149                 onConnectionLost();
150             } finally {
151                 pollingLock.unlock();
152             }
153         }
154
155         private boolean isReachable(String ipAddress) {
156             try {
157                 // note that InetAddress.isReachable is unreliable, see
158                 // http://stackoverflow.com/questions/9922543/why-does-inetaddress-isreachable-return-false-when-i-can-ping-the-ip-address
159                 // That's why we do an HTTP access instead
160
161                 // If there is no connection, this line will fail
162                 hueBridge.authenticate("invalid");
163             } catch (IOException e) {
164                 return false;
165             } catch (ApiException e) {
166                 if (e.getMessage().contains("SocketTimeout") || e.getMessage().contains("ConnectException")
167                         || e.getMessage().contains("SocketException")
168                         || e.getMessage().contains("NoRouteToHostException")) {
169                     return false;
170                 } else {
171                     // this seems to be only an authentication issue
172                     return true;
173                 }
174             }
175             return true;
176         }
177
178         protected abstract void doConnectedRun() throws IOException, ApiException;
179     }
180
181     private final Runnable sensorPollingRunnable = new PollingRunnable() {
182         @Override
183         protected void doConnectedRun() throws IOException, ApiException {
184             Map<String, @Nullable FullSensor> lastSensorStateCopy = new HashMap<>(lastSensorStates);
185
186             final HueLightDiscoveryService discovery = discoveryService;
187
188             for (final FullSensor sensor : hueBridge.getSensors()) {
189                 String sensorId = sensor.getId();
190
191                 final SensorStatusListener sensorStatusListener = sensorStatusListeners.get(sensorId);
192                 if (sensorStatusListener == null) {
193                     logger.trace("Hue sensor '{}' added.", sensorId);
194
195                     if (discovery != null && !lastSensorStateCopy.containsKey(sensorId)) {
196                         discovery.addSensorDiscovery(sensor);
197                     }
198
199                     lastSensorStates.put(sensorId, sensor);
200                 } else {
201                     if (sensorStatusListener.onSensorStateChanged(sensor)) {
202                         lastSensorStates.put(sensorId, sensor);
203                     }
204                 }
205                 lastSensorStateCopy.remove(sensorId);
206             }
207
208             // Check for removed sensors
209             lastSensorStateCopy.forEach((sensorId, sensor) -> {
210                 logger.trace("Hue sensor '{}' removed.", sensorId);
211                 lastSensorStates.remove(sensorId);
212
213                 final SensorStatusListener sensorStatusListener = sensorStatusListeners.get(sensorId);
214                 if (sensorStatusListener != null) {
215                     sensorStatusListener.onSensorRemoved();
216                 }
217
218                 if (discovery != null && sensor != null) {
219                     discovery.removeSensorDiscovery(sensor);
220                 }
221             });
222         }
223     };
224
225     private final Runnable lightPollingRunnable = new PollingRunnable() {
226         @Override
227         protected void doConnectedRun() throws IOException, ApiException {
228             updateLights();
229             updateGroups();
230         }
231
232         private void updateLights() throws IOException, ApiException {
233             Map<String, @Nullable FullLight> lastLightStateCopy = new HashMap<>(lastLightStates);
234
235             List<FullLight> lights;
236             if (ApiVersionUtils.supportsFullLights(hueBridge.getVersion())) {
237                 lights = hueBridge.getFullLights();
238             } else {
239                 lights = hueBridge.getFullConfig().getLights();
240             }
241
242             final HueLightDiscoveryService discovery = discoveryService;
243
244             for (final FullLight fullLight : lights) {
245                 final String lightId = fullLight.getId();
246
247                 final LightStatusListener lightStatusListener = lightStatusListeners.get(lightId);
248                 if (lightStatusListener == null) {
249                     logger.trace("Hue light '{}' added.", lightId);
250
251                     if (discovery != null && !lastLightStateCopy.containsKey(lightId)) {
252                         discovery.addLightDiscovery(fullLight);
253                     }
254
255                     lastLightStates.put(lightId, fullLight);
256                 } else {
257                     if (lightStatusListener.onLightStateChanged(fullLight)) {
258                         lastLightStates.put(lightId, fullLight);
259                     }
260                 }
261                 lastLightStateCopy.remove(lightId);
262             }
263
264             // Check for removed lights
265             lastLightStateCopy.forEach((lightId, light) -> {
266                 logger.trace("Hue light '{}' removed.", lightId);
267                 lastLightStates.remove(lightId);
268
269                 final LightStatusListener lightStatusListener = lightStatusListeners.get(lightId);
270                 if (lightStatusListener != null) {
271                     lightStatusListener.onLightRemoved();
272                 }
273
274                 if (discovery != null && light != null) {
275                     discovery.removeLightDiscovery(light);
276                 }
277             });
278         }
279
280         private void updateGroups() throws IOException, ApiException {
281             Map<String, @Nullable FullGroup> lastGroupStateCopy = new HashMap<>(lastGroupStates);
282
283             List<FullGroup> groups = hueBridge.getGroups();
284
285             final HueLightDiscoveryService discovery = discoveryService;
286
287             for (final FullGroup fullGroup : groups) {
288                 State groupState = new State();
289                 boolean on = false;
290                 int sumBri = 0;
291                 int nbBri = 0;
292                 State colorRef = null;
293                 HSBType firstColorHsb = null;
294                 for (String lightId : fullGroup.getLightIds()) {
295                     FullLight light = lastLightStates.get(lightId);
296                     if (light != null) {
297                         final State lightState = light.getState();
298                         logger.trace("Group {}: light {}: on {} bri {} hue {} sat {} temp {} mode {} XY {}",
299                                 fullGroup.getName(), light.getName(), lightState.isOn(), lightState.getBrightness(),
300                                 lightState.getHue(), lightState.getSaturation(), lightState.getColorTemperature(),
301                                 lightState.getColorMode(), lightState.getXY());
302                         if (lightState.isOn()) {
303                             on = true;
304                             sumBri += lightState.getBrightness();
305                             nbBri++;
306                             if (lightState.getColorMode() != null) {
307                                 HSBType lightHsb = LightStateConverter.toHSBType(lightState);
308                                 if (firstColorHsb == null) {
309                                     // first color light
310                                     firstColorHsb = lightHsb;
311                                     colorRef = lightState;
312                                 } else if (!lightHsb.equals(firstColorHsb)) {
313                                     colorRef = null;
314                                 }
315                             }
316                         }
317                     }
318                 }
319                 groupState.setOn(on);
320                 groupState.setBri(nbBri == 0 ? 0 : sumBri / nbBri);
321                 if (colorRef != null) {
322                     groupState.setColormode(colorRef.getColorMode());
323                     groupState.setHue(colorRef.getHue());
324                     groupState.setSaturation(colorRef.getSaturation());
325                     groupState.setColorTemperature(colorRef.getColorTemperature());
326                     groupState.setXY(colorRef.getXY());
327                 }
328                 fullGroup.setState(groupState);
329                 logger.trace("Group {} ({}): on {} bri {} hue {} sat {} temp {} mode {} XY {}", fullGroup.getName(),
330                         fullGroup.getType(), groupState.isOn(), groupState.getBrightness(), groupState.getHue(),
331                         groupState.getSaturation(), groupState.getColorTemperature(), groupState.getColorMode(),
332                         groupState.getXY());
333
334                 String groupId = fullGroup.getId();
335
336                 final GroupStatusListener groupStatusListener = groupStatusListeners.get(groupId);
337                 if (groupStatusListener == null) {
338                     logger.trace("Hue group '{}' ({}) added (nb lights {}).", groupId, fullGroup.getName(),
339                             fullGroup.getLightIds().size());
340
341                     if (discovery != null && !lastGroupStateCopy.containsKey(groupId)) {
342                         discovery.addGroupDiscovery(fullGroup);
343                     }
344
345                     lastGroupStates.put(groupId, fullGroup);
346                 } else {
347                     if (groupStatusListener.onGroupStateChanged(fullGroup)) {
348                         lastGroupStates.put(groupId, fullGroup);
349                     }
350                 }
351                 lastGroupStateCopy.remove(groupId);
352             }
353
354             // Check for removed groups
355             lastGroupStateCopy.forEach((groupId, group) -> {
356                 logger.trace("Hue group '{}' removed.", groupId);
357                 lastGroupStates.remove(groupId);
358
359                 final GroupStatusListener groupStatusListener = groupStatusListeners.get(groupId);
360                 if (groupStatusListener != null) {
361                     groupStatusListener.onGroupRemoved();
362                 }
363
364                 if (discovery != null && group != null) {
365                     discovery.removeGroupDiscovery(group);
366                 }
367             });
368         }
369     };
370
371     private final Runnable scenePollingRunnable = new PollingRunnable() {
372         @Override
373         protected void doConnectedRun() throws IOException, ApiException {
374             List<Scene> scenes = hueBridge.getScenes();
375             logger.trace("Scenes detected: {}", scenes);
376
377             setBridgeSceneChannelStateOptions(scenes, lastGroupStates);
378             notifyGroupSceneUpdate(scenes);
379         }
380
381         private void setBridgeSceneChannelStateOptions(List<Scene> scenes, Map<String, @Nullable FullGroup> groups) {
382             Map<String, String> groupNames = groups.entrySet().stream()
383                     .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().getName()));
384             List<StateOption> stateOptions = scenes.stream().map(scene -> scene.toStateOption(groupNames))
385                     .collect(Collectors.toList());
386             stateDescriptionOptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), CHANNEL_SCENE),
387                     stateOptions);
388             consoleScenesList = scenes.stream().map(scene -> "Id is \"" + scene.getId() + "\" for scene \""
389                     + scene.toStateOption(groupNames).getLabel() + "\"").collect(Collectors.toList());
390         }
391     };
392
393     private boolean lastBridgeConnectionState = false;
394
395     private boolean propertiesInitializedSuccessfully = false;
396
397     private @Nullable Future<?> initJob;
398     private @Nullable ScheduledFuture<?> lightPollingJob;
399     private @Nullable ScheduledFuture<?> sensorPollingJob;
400     private @Nullable ScheduledFuture<?> scenePollingJob;
401
402     private @NonNullByDefault({}) HueBridge hueBridge = null;
403     private @NonNullByDefault({}) HueBridgeConfig hueBridgeConfig = null;
404
405     private List<String> consoleScenesList = new ArrayList<>();
406
407     public HueBridgeHandler(Bridge bridge, HueStateDescriptionOptionProvider stateDescriptionOptionProvider) {
408         super(bridge);
409         this.stateDescriptionOptionProvider = stateDescriptionOptionProvider;
410     }
411
412     @Override
413     public void handleCommand(ChannelUID channelUID, Command command) {
414         if (CHANNEL_SCENE.equals(channelUID.getId()) && command instanceof StringType) {
415             recallScene(command.toString());
416         }
417     }
418
419     @Override
420     public void updateLightState(LightStatusListener listener, FullLight light, StateUpdate stateUpdate,
421             long fadeTime) {
422         if (hueBridge != null) {
423             listener.setPollBypass(BYPASS_MIN_DURATION_BEFORE_CMD);
424             hueBridge.setLightState(light, stateUpdate).thenAccept(result -> {
425                 try {
426                     hueBridge.handleErrors(result);
427                     listener.setPollBypass(fadeTime);
428                 } catch (Exception e) {
429                     listener.unsetPollBypass();
430                     handleLightUpdateException(listener, light, stateUpdate, fadeTime, e);
431                 }
432             }).exceptionally(e -> {
433                 listener.unsetPollBypass();
434                 handleLightUpdateException(listener, light, stateUpdate, fadeTime, e);
435                 return null;
436             });
437         } else {
438             logger.debug("No bridge connected or selected. Cannot set light state.");
439         }
440     }
441
442     @Override
443     public void updateSensorState(FullSensor sensor, StateUpdate stateUpdate) {
444         if (hueBridge != null) {
445             hueBridge.setSensorState(sensor, stateUpdate).thenAccept(result -> {
446                 try {
447                     hueBridge.handleErrors(result);
448                 } catch (Exception e) {
449                     handleSensorUpdateException(sensor, e);
450                 }
451             }).exceptionally(e -> {
452                 handleSensorUpdateException(sensor, e);
453                 return null;
454             });
455         } else {
456             logger.debug("No bridge connected or selected. Cannot set sensor state.");
457         }
458     }
459
460     @Override
461     public void updateSensorConfig(FullSensor sensor, ConfigUpdate configUpdate) {
462         if (hueBridge != null) {
463             hueBridge.updateSensorConfig(sensor, configUpdate).thenAccept(result -> {
464                 try {
465                     hueBridge.handleErrors(result);
466                 } catch (Exception e) {
467                     handleSensorUpdateException(sensor, e);
468                 }
469             }).exceptionally(e -> {
470                 handleSensorUpdateException(sensor, e);
471                 return null;
472             });
473         } else {
474             logger.debug("No bridge connected or selected. Cannot set sensor config.");
475         }
476     }
477
478     @Override
479     public void updateGroupState(FullGroup group, StateUpdate stateUpdate, long fadeTime) {
480         if (hueBridge != null) {
481             setGroupPollBypass(group, BYPASS_MIN_DURATION_BEFORE_CMD);
482             hueBridge.setGroupState(group, stateUpdate).thenAccept(result -> {
483                 try {
484                     hueBridge.handleErrors(result);
485                     setGroupPollBypass(group, fadeTime);
486                 } catch (Exception e) {
487                     unsetGroupPollBypass(group);
488                     handleGroupUpdateException(group, e);
489                 }
490             }).exceptionally(e -> {
491                 unsetGroupPollBypass(group);
492                 handleGroupUpdateException(group, e);
493                 return null;
494             });
495         } else {
496             logger.debug("No bridge connected or selected. Cannot set group state.");
497         }
498     }
499
500     private void setGroupPollBypass(FullGroup group, long bypassTime) {
501         group.getLightIds().forEach((lightId) -> {
502             final LightStatusListener listener = lightStatusListeners.get(lightId);
503             if (listener != null) {
504                 listener.setPollBypass(bypassTime);
505             }
506         });
507     }
508
509     private void unsetGroupPollBypass(FullGroup group) {
510         group.getLightIds().forEach((lightId) -> {
511             final LightStatusListener listener = lightStatusListeners.get(lightId);
512             if (listener != null) {
513                 listener.unsetPollBypass();
514             }
515         });
516     }
517
518     private void handleLightUpdateException(LightStatusListener listener, FullLight light, StateUpdate stateUpdate,
519             long fadeTime, Throwable e) {
520         if (e instanceof DeviceOffException) {
521             if (stateUpdate.getColorTemperature() != null && stateUpdate.getBrightness() == null) {
522                 // If there is only a change of the color temperature, we do not want the light
523                 // to be turned on (i.e. change its brightness).
524                 return;
525             } else {
526                 updateLightState(listener, light, LightStateConverter.toOnOffLightState(OnOffType.ON), fadeTime);
527                 updateLightState(listener, light, stateUpdate, fadeTime);
528             }
529         } else if (e instanceof EntityNotAvailableException) {
530             logger.debug("Error while accessing light: {}", e.getMessage(), e);
531             final HueLightDiscoveryService discovery = discoveryService;
532             if (discovery != null) {
533                 discovery.removeLightDiscovery(light);
534             }
535             listener.onLightGone();
536         } else {
537             handleThingUpdateException("light", e);
538         }
539     }
540
541     private void handleSensorUpdateException(FullSensor sensor, Throwable e) {
542         if (e instanceof EntityNotAvailableException) {
543             logger.debug("Error while accessing sensor: {}", e.getMessage(), e);
544             final HueLightDiscoveryService discovery = discoveryService;
545             if (discovery != null) {
546                 discovery.removeSensorDiscovery(sensor);
547             }
548             final SensorStatusListener listener = sensorStatusListeners.get(sensor.getId());
549             if (listener != null) {
550                 listener.onSensorGone();
551             }
552         } else {
553             handleThingUpdateException("sensor", e);
554         }
555     }
556
557     private void handleGroupUpdateException(FullGroup group, Throwable e) {
558         if (e instanceof EntityNotAvailableException) {
559             logger.debug("Error while accessing group: {}", e.getMessage(), e);
560             final HueLightDiscoveryService discovery = discoveryService;
561             if (discovery != null) {
562                 discovery.removeGroupDiscovery(group);
563             }
564             final GroupStatusListener listener = groupStatusListeners.get(group.getId());
565             if (listener != null) {
566                 listener.onGroupGone();
567             }
568         } else {
569             handleThingUpdateException("group", e);
570         }
571     }
572
573     private void handleThingUpdateException(String thingType, Throwable e) {
574         if (e instanceof IOException) {
575             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
576         } else if (e instanceof ApiException) {
577             // This should not happen - if it does, it is most likely some bug that should be reported.
578             logger.warn("Error while accessing {}: {}", thingType, e.getMessage());
579         } else if (e instanceof IllegalStateException) {
580             logger.trace("Error while accessing {}: {}", thingType, e.getMessage());
581         }
582     }
583
584     private void startLightPolling() {
585         ScheduledFuture<?> job = lightPollingJob;
586         if (job == null || job.isCancelled()) {
587             long lightPollingInterval;
588             int configPollingInterval = hueBridgeConfig.getPollingInterval();
589             if (configPollingInterval < 1) {
590                 lightPollingInterval = TimeUnit.SECONDS.toSeconds(10);
591                 logger.info("Wrong configuration value for polling interval. Using default value: {}s",
592                         lightPollingInterval);
593             } else {
594                 lightPollingInterval = configPollingInterval;
595             }
596             // Delay the first execution to give a chance to have all light and group things registered
597             lightPollingJob = scheduler.scheduleWithFixedDelay(lightPollingRunnable, 3, lightPollingInterval,
598                     TimeUnit.SECONDS);
599         }
600     }
601
602     private void stopLightPolling() {
603         ScheduledFuture<?> job = lightPollingJob;
604         if (job != null) {
605             job.cancel(true);
606         }
607         lightPollingJob = null;
608     }
609
610     private void startSensorPolling() {
611         ScheduledFuture<?> job = sensorPollingJob;
612         if (job == null || job.isCancelled()) {
613             int configSensorPollingInterval = hueBridgeConfig.getSensorPollingInterval();
614             if (configSensorPollingInterval > 0) {
615                 long sensorPollingInterval;
616                 if (configSensorPollingInterval < 50) {
617                     sensorPollingInterval = TimeUnit.MILLISECONDS.toMillis(500);
618                     logger.info("Wrong configuration value for sensor polling interval. Using default value: {}ms",
619                             sensorPollingInterval);
620                 } else {
621                     sensorPollingInterval = configSensorPollingInterval;
622                 }
623                 // Delay the first execution to give a chance to have all sensor things registered
624                 sensorPollingJob = scheduler.scheduleWithFixedDelay(sensorPollingRunnable, 4000, sensorPollingInterval,
625                         TimeUnit.MILLISECONDS);
626             }
627         }
628     }
629
630     private void stopSensorPolling() {
631         ScheduledFuture<?> job = sensorPollingJob;
632         if (job != null) {
633             job.cancel(true);
634         }
635         sensorPollingJob = null;
636     }
637
638     private void startScenePolling() {
639         ScheduledFuture<?> job = scenePollingJob;
640         if (job == null || job.isCancelled()) {
641             // Delay the first execution to give a chance to have all group things registered
642             scenePollingJob = scheduler.scheduleWithFixedDelay(scenePollingRunnable, 5, SCENE_POLLING_INTERVAL,
643                     TimeUnit.SECONDS);
644         }
645     }
646
647     private void stopScenePolling() {
648         ScheduledFuture<?> job = scenePollingJob;
649         if (job != null) {
650             job.cancel(true);
651         }
652         scenePollingJob = null;
653     }
654
655     @Override
656     public void dispose() {
657         logger.debug("Handler disposed.");
658         Future<?> job = initJob;
659         if (job != null) {
660             job.cancel(true);
661         }
662         stopLightPolling();
663         stopSensorPolling();
664         stopScenePolling();
665         if (hueBridge != null) {
666             hueBridge = null;
667         }
668     }
669
670     @Override
671     public void initialize() {
672         logger.debug("Initializing hue bridge handler.");
673         hueBridgeConfig = getConfigAs(HueBridgeConfig.class);
674
675         String ip = hueBridgeConfig.getIpAddress();
676         if (ip == null || ip.isEmpty()) {
677             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
678                     "@text/offline.conf-error-no-ip-address");
679         } else {
680             if (hueBridge == null) {
681                 hueBridge = new HueBridge(ip, hueBridgeConfig.getPort(), hueBridgeConfig.getProtocol(), scheduler);
682                 hueBridge.setTimeout(5000);
683
684                 // Try a first connection that will fail, then try to authenticate,
685                 // and finally change the bridge status to ONLINE
686                 initJob = scheduler.submit(new PollingRunnable() {
687                     @Override
688                     protected void doConnectedRun() throws IOException, ApiException {
689                     }
690                 });
691             }
692             onUpdate();
693         }
694     }
695
696     public @Nullable String getUserName() {
697         return hueBridgeConfig == null ? null : hueBridgeConfig.getUserName();
698     }
699
700     private synchronized void onUpdate() {
701         if (hueBridge != null) {
702             startLightPolling();
703             startSensorPolling();
704             startScenePolling();
705         }
706     }
707
708     /**
709      * This method is called whenever the connection to the {@link HueBridge} is lost.
710      */
711     public void onConnectionLost() {
712         logger.debug("Bridge connection lost. Updating thing status to OFFLINE.");
713         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "@text/offline.bridge-connection-lost");
714     }
715
716     /**
717      * This method is called whenever the connection to the {@link HueBridge} is resumed.
718      *
719      * @throws ApiException if the physical device does not support this API call
720      * @throws IOException if the physical device could not be reached
721      */
722     private void onConnectionResumed() throws IOException, ApiException {
723         logger.debug("Bridge connection resumed.");
724
725         if (!propertiesInitializedSuccessfully) {
726             FullConfig fullConfig = hueBridge.getFullConfig();
727             Config config = fullConfig.getConfig();
728             if (config != null) {
729                 Map<String, String> properties = editProperties();
730                 String serialNumber = config.getBridgeId().substring(0, 6) + config.getBridgeId().substring(10);
731                 properties.put(PROPERTY_SERIAL_NUMBER, serialNumber);
732                 properties.put(PROPERTY_MODEL_ID, config.getModelId());
733                 properties.put(PROPERTY_MAC_ADDRESS, config.getMACAddress());
734                 properties.put(PROPERTY_FIRMWARE_VERSION, config.getSoftwareVersion());
735                 updateProperties(properties);
736                 propertiesInitializedSuccessfully = true;
737             }
738         }
739     }
740
741     /**
742      * Check USER_NAME config for null. Call onConnectionResumed() otherwise.
743      *
744      * @return True if USER_NAME was not null.
745      * @throws ApiException if the physical device does not support this API call
746      * @throws IOException if the physical device could not be reached
747      */
748     private boolean tryResumeBridgeConnection() throws IOException, ApiException {
749         logger.debug("Connection to Hue Bridge {} established.", hueBridge.getIPAddress());
750         if (hueBridgeConfig.getUserName() == null) {
751             logger.warn(
752                     "User name for Hue bridge authentication not available in configuration. Setting ThingStatus to OFFLINE.");
753             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
754                     "@text/offline.conf-error-no-username");
755             return false;
756         } else {
757             onConnectionResumed();
758             return true;
759         }
760     }
761
762     /**
763      * This method is called whenever the connection to the {@link HueBridge} is available,
764      * but requests are not allowed due to a missing or invalid authentication.
765      * <p>
766      * If there is a user name available, it attempts to re-authenticate. Otherwise new authentication credentials will
767      * be requested from the bridge.
768      *
769      * @param bridge the hue bridge the connection is not authorized
770      * @return returns {@code true} if re-authentication was successful, {@code false} otherwise
771      */
772     public boolean onNotAuthenticated() {
773         if (hueBridge == null) {
774             return false;
775         }
776         String userName = hueBridgeConfig.getUserName();
777         if (userName == null) {
778             createUser();
779         } else {
780             try {
781                 hueBridge.authenticate(userName);
782                 return true;
783             } catch (Exception e) {
784                 handleAuthenticationFailure(e, userName);
785             }
786         }
787         return false;
788     }
789
790     private void createUser() {
791         try {
792             String newUser = createUserOnPhysicalBridge();
793             updateBridgeThingConfiguration(newUser);
794         } catch (LinkButtonException ex) {
795             handleLinkButtonNotPressed(ex);
796         } catch (Exception ex) {
797             handleExceptionWhileCreatingUser(ex);
798         }
799     }
800
801     private String createUserOnPhysicalBridge() throws IOException, ApiException {
802         logger.info("Creating new user on Hue bridge {} - please press the pairing button on the bridge.",
803                 hueBridgeConfig.getIpAddress());
804         String userName = hueBridge.link(DEVICE_TYPE);
805         logger.info("User has been successfully added to Hue bridge.");
806         return userName;
807     }
808
809     private void updateBridgeThingConfiguration(String userName) {
810         Configuration config = editConfiguration();
811         config.put(USER_NAME, userName);
812         try {
813             updateConfiguration(config);
814             logger.debug("Updated configuration parameter '{}'", USER_NAME);
815             hueBridgeConfig = getConfigAs(HueBridgeConfig.class);
816         } catch (IllegalStateException e) {
817             logger.trace("Configuration update failed.", e);
818             logger.warn("Unable to update configuration of Hue bridge.");
819             logger.warn("Please configure the user name manually.");
820         }
821     }
822
823     private void handleAuthenticationFailure(Exception ex, String userName) {
824         logger.warn("User is not authenticated on Hue bridge {}", hueBridgeConfig.getIpAddress());
825         logger.warn("Please configure a valid user or remove user from configuration to generate a new one.");
826         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
827                 "@text/offline.conf-error-invalid-username");
828     }
829
830     private void handleLinkButtonNotPressed(LinkButtonException ex) {
831         logger.debug("Failed creating new user on Hue bridge: {}", ex.getMessage());
832         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
833                 "@text/offline.conf-error-press-pairing-button");
834     }
835
836     private void handleExceptionWhileCreatingUser(Exception ex) {
837         logger.warn("Failed creating new user on Hue bridge", ex);
838         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
839                 "@text/offline.conf-error-creation-username");
840     }
841
842     @Override
843     public boolean registerDiscoveryListener(HueLightDiscoveryService listener) {
844         if (discoveryService == null) {
845             discoveryService = listener;
846             getFullLights().forEach(listener::addLightDiscovery);
847             getFullSensors().forEach(listener::addSensorDiscovery);
848             getFullGroups().forEach(listener::addGroupDiscovery);
849             return true;
850         }
851
852         return false;
853     }
854
855     @Override
856     public boolean unregisterDiscoveryListener() {
857         if (discoveryService != null) {
858             discoveryService = null;
859             return true;
860         }
861
862         return false;
863     }
864
865     @Override
866     public boolean registerLightStatusListener(LightStatusListener lightStatusListener) {
867         final String lightId = lightStatusListener.getLightId();
868         if (!lightStatusListeners.containsKey(lightId)) {
869             lightStatusListeners.put(lightId, lightStatusListener);
870             final FullLight lastLightState = lastLightStates.get(lightId);
871             if (lastLightState != null) {
872                 lightStatusListener.onLightAdded(lastLightState);
873             }
874
875             return true;
876         }
877         return false;
878     }
879
880     @Override
881     public boolean unregisterLightStatusListener(LightStatusListener lightStatusListener) {
882         return lightStatusListeners.remove(lightStatusListener.getLightId()) != null;
883     }
884
885     @Override
886     public boolean registerSensorStatusListener(SensorStatusListener sensorStatusListener) {
887         final String sensorId = sensorStatusListener.getSensorId();
888         if (!sensorStatusListeners.containsKey(sensorId)) {
889             sensorStatusListeners.put(sensorId, sensorStatusListener);
890             final FullSensor lastSensorState = lastSensorStates.get(sensorId);
891             if (lastSensorState != null) {
892                 sensorStatusListener.onSensorAdded(lastSensorState);
893             }
894             return true;
895         }
896
897         return false;
898     }
899
900     @Override
901     public boolean unregisterSensorStatusListener(SensorStatusListener sensorStatusListener) {
902         return sensorStatusListeners.remove(sensorStatusListener.getSensorId()) != null;
903     }
904
905     @Override
906     public boolean registerGroupStatusListener(GroupStatusListener groupStatusListener) {
907         final String groupId = groupStatusListener.getGroupId();
908         if (!groupStatusListeners.containsKey(groupId)) {
909             groupStatusListeners.put(groupId, groupStatusListener);
910             final FullGroup lastGroupState = lastGroupStates.get(groupId);
911             if (lastGroupState != null) {
912                 groupStatusListener.onGroupAdded(lastGroupState);
913             }
914             return true;
915         }
916
917         return false;
918     }
919
920     @Override
921     public boolean unregisterGroupStatusListener(GroupStatusListener groupStatusListener) {
922         return groupStatusListeners.remove(groupStatusListener.getGroupId()) != null;
923     }
924
925     /**
926      * Recall scene to all lights that belong to the scene.
927      *
928      * @param id the ID of the scene to activate
929      */
930     @Override
931     public void recallScene(String id) {
932         if (hueBridge != null) {
933             hueBridge.recallScene(id).thenAccept(result -> {
934                 try {
935                     hueBridge.handleErrors(result);
936                 } catch (Exception e) {
937                     logger.debug("Error while recalling scene: {}", e.getMessage());
938                 }
939             }).exceptionally(e -> {
940                 logger.debug("Error while recalling scene: {}", e.getMessage());
941                 return null;
942             });
943         } else {
944             logger.debug("No bridge connected or selected. Cannot activate scene.");
945         }
946     }
947
948     @Override
949     public @Nullable FullLight getLightById(String lightId) {
950         return lastLightStates.get(lightId);
951     }
952
953     @Override
954     public @Nullable FullSensor getSensorById(String sensorId) {
955         return lastSensorStates.get(sensorId);
956     }
957
958     @Override
959     public @Nullable FullGroup getGroupById(String groupId) {
960         return lastGroupStates.get(groupId);
961     }
962
963     public List<FullLight> getFullLights() {
964         List<FullLight> ret = withReAuthentication("search for new lights", () -> {
965             return hueBridge.getFullLights();
966         });
967         return ret != null ? ret : Collections.emptyList();
968     }
969
970     public List<FullSensor> getFullSensors() {
971         List<FullSensor> ret = withReAuthentication("search for new sensors", () -> {
972             return hueBridge.getSensors();
973         });
974         return ret != null ? ret : Collections.emptyList();
975     }
976
977     public List<FullGroup> getFullGroups() {
978         List<FullGroup> ret = withReAuthentication("search for new groups", () -> {
979             return hueBridge.getGroups();
980         });
981         return ret != null ? ret : Collections.emptyList();
982     }
983
984     public void startSearch() {
985         withReAuthentication("start search mode", () -> {
986             hueBridge.startSearch();
987             return null;
988         });
989     }
990
991     public void startSearch(List<String> serialNumbers) {
992         withReAuthentication("start search mode", () -> {
993             hueBridge.startSearch(serialNumbers);
994             return null;
995         });
996     }
997
998     private @Nullable <T> T withReAuthentication(String taskDescription, Callable<T> runnable) {
999         if (hueBridge != null) {
1000             try {
1001                 try {
1002                     return runnable.call();
1003                 } catch (UnauthorizedException | IllegalStateException e) {
1004                     lastBridgeConnectionState = false;
1005                     if (onNotAuthenticated()) {
1006                         return runnable.call();
1007                     }
1008                 }
1009             } catch (Exception e) {
1010                 logger.debug("Bridge cannot {}.", taskDescription, e);
1011             }
1012         }
1013         return null;
1014     }
1015
1016     private void notifyGroupSceneUpdate(List<Scene> scenes) {
1017         groupStatusListeners.forEach((groupId, listener) -> listener.onScenesUpdated(scenes));
1018     }
1019
1020     public List<String> listScenesForConsole() {
1021         return consoleScenesList;
1022     }
1023
1024     @Override
1025     public Collection<ConfigStatusMessage> getConfigStatus() {
1026         // The bridge IP address to be used for checks
1027         Collection<ConfigStatusMessage> configStatusMessages;
1028
1029         // Check whether an IP address is provided
1030         String ip = hueBridgeConfig.getIpAddress();
1031         if (ip == null || ip.isEmpty()) {
1032             configStatusMessages = Collections.singletonList(ConfigStatusMessage.Builder.error(HOST)
1033                     .withMessageKeySuffix(HueConfigStatusMessage.IP_ADDRESS_MISSING).withArguments(HOST).build());
1034         } else {
1035             configStatusMessages = Collections.emptyList();
1036         }
1037
1038         return configStatusMessages;
1039     }
1040 }