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