]> git.basschouten.com Git - openhab-addons.git/blob
2f37def51ddc91cb7d3a9b34204db895bb31e783
[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 && sensor != 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 && light != 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 && group != 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))
406                     .collect(Collectors.toList());
407             stateDescriptionOptionProvider.setStateOptions(new ChannelUID(getThing().getUID(), CHANNEL_SCENE),
408                     stateOptions);
409             consoleScenesList = scenes.stream().map(scene -> "Id is \"" + scene.getId() + "\" for scene \""
410                     + scene.toStateOption(groupNames).getLabel() + "\"").collect(Collectors.toList());
411         }
412     };
413
414     private boolean lastBridgeConnectionState = false;
415
416     private boolean propertiesInitializedSuccessfully = false;
417
418     private @Nullable Future<?> initJob;
419     private @Nullable ScheduledFuture<?> lightPollingJob;
420     private @Nullable ScheduledFuture<?> sensorPollingJob;
421
422     private @NonNullByDefault({}) HueBridge hueBridge = null;
423     private @NonNullByDefault({}) HueBridgeConfig hueBridgeConfig = null;
424
425     private List<String> consoleScenesList = new ArrayList<>();
426
427     public HueBridgeHandler(Bridge bridge, HttpClient httpClient,
428             HueStateDescriptionProvider stateDescriptionOptionProvider, TranslationProvider i18nProvider,
429             LocaleProvider localeProvider) {
430         super(bridge);
431         this.httpClient = httpClient;
432         this.stateDescriptionOptionProvider = stateDescriptionOptionProvider;
433         this.i18nProvider = i18nProvider;
434         this.localeProvider = localeProvider;
435     }
436
437     @Override
438     public Collection<Class<? extends ThingHandlerService>> getServices() {
439         return Set.of(HueDeviceDiscoveryService.class);
440     }
441
442     @Override
443     public void handleCommand(ChannelUID channelUID, Command command) {
444         if (CHANNEL_SCENE.equals(channelUID.getId()) && command instanceof StringType) {
445             recallScene(command.toString());
446         }
447     }
448
449     @Override
450     public void updateLightState(LightStatusListener listener, FullLight light, StateUpdate stateUpdate,
451             long fadeTime) {
452         if (hueBridge != null) {
453             listener.setPollBypass(BYPASS_MIN_DURATION_BEFORE_CMD);
454             hueBridge.setLightState(light, stateUpdate).thenAccept(result -> {
455                 try {
456                     hueBridge.handleErrors(result);
457                     listener.setPollBypass(fadeTime);
458                 } catch (Exception e) {
459                     listener.unsetPollBypass();
460                     handleLightUpdateException(listener, light, stateUpdate, fadeTime, e);
461                 }
462             }).exceptionally(e -> {
463                 listener.unsetPollBypass();
464                 handleLightUpdateException(listener, light, stateUpdate, fadeTime, e);
465                 return null;
466             });
467         } else {
468             logger.debug("No bridge connected or selected. Cannot set light state.");
469         }
470     }
471
472     @Override
473     public void updateSensorState(FullSensor sensor, StateUpdate stateUpdate) {
474         if (hueBridge != null) {
475             hueBridge.setSensorState(sensor, stateUpdate).thenAccept(result -> {
476                 try {
477                     hueBridge.handleErrors(result);
478                 } catch (Exception e) {
479                     handleSensorUpdateException(sensor, e);
480                 }
481             }).exceptionally(e -> {
482                 handleSensorUpdateException(sensor, e);
483                 return null;
484             });
485         } else {
486             logger.debug("No bridge connected or selected. Cannot set sensor state.");
487         }
488     }
489
490     @Override
491     public void updateSensorConfig(FullSensor sensor, ConfigUpdate configUpdate) {
492         if (hueBridge != null) {
493             hueBridge.updateSensorConfig(sensor, configUpdate).thenAccept(result -> {
494                 try {
495                     hueBridge.handleErrors(result);
496                 } catch (Exception e) {
497                     handleSensorUpdateException(sensor, e);
498                 }
499             }).exceptionally(e -> {
500                 handleSensorUpdateException(sensor, e);
501                 return null;
502             });
503         } else {
504             logger.debug("No bridge connected or selected. Cannot set sensor config.");
505         }
506     }
507
508     @Override
509     public void updateGroupState(FullGroup group, StateUpdate stateUpdate, long fadeTime) {
510         if (hueBridge != null) {
511             setGroupPollBypass(group, BYPASS_MIN_DURATION_BEFORE_CMD);
512             hueBridge.setGroupState(group, stateUpdate).thenAccept(result -> {
513                 try {
514                     hueBridge.handleErrors(result);
515                     setGroupPollBypass(group, fadeTime);
516                 } catch (Exception e) {
517                     unsetGroupPollBypass(group);
518                     handleGroupUpdateException(group, e);
519                 }
520             }).exceptionally(e -> {
521                 unsetGroupPollBypass(group);
522                 handleGroupUpdateException(group, e);
523                 return null;
524             });
525         } else {
526             logger.debug("No bridge connected or selected. Cannot set group state.");
527         }
528     }
529
530     private void setGroupPollBypass(FullGroup group, long bypassTime) {
531         group.getLightIds().forEach((lightId) -> {
532             final LightStatusListener listener = lightStatusListeners.get(lightId);
533             if (listener != null) {
534                 listener.setPollBypass(bypassTime);
535             }
536         });
537     }
538
539     private void unsetGroupPollBypass(FullGroup group) {
540         group.getLightIds().forEach((lightId) -> {
541             final LightStatusListener listener = lightStatusListeners.get(lightId);
542             if (listener != null) {
543                 listener.unsetPollBypass();
544             }
545         });
546     }
547
548     private void handleLightUpdateException(LightStatusListener listener, FullLight light, StateUpdate stateUpdate,
549             long fadeTime, Throwable e) {
550         if (e instanceof DeviceOffException) {
551             if (stateUpdate.getColorTemperature() != null && stateUpdate.getBrightness() == null) {
552                 // If there is only a change of the color temperature, we do not want the light
553                 // to be turned on (i.e. change its brightness).
554                 return;
555             } else {
556                 updateLightState(listener, light, LightStateConverter.toOnOffLightState(OnOffType.ON), fadeTime);
557                 updateLightState(listener, light, stateUpdate, fadeTime);
558             }
559         } else if (e instanceof EntityNotAvailableException) {
560             logger.debug("Error while accessing light: {}", e.getMessage(), e);
561             final HueDeviceDiscoveryService discovery = discoveryService;
562             if (discovery != null) {
563                 discovery.removeLightDiscovery(light);
564             }
565             listener.onLightGone();
566         } else {
567             handleThingUpdateException("light", e);
568         }
569     }
570
571     private void handleSensorUpdateException(FullSensor sensor, Throwable e) {
572         if (e instanceof EntityNotAvailableException) {
573             logger.debug("Error while accessing sensor: {}", e.getMessage(), e);
574             final HueDeviceDiscoveryService discovery = discoveryService;
575             if (discovery != null) {
576                 discovery.removeSensorDiscovery(sensor);
577             }
578             final SensorStatusListener listener = sensorStatusListeners.get(sensor.getId());
579             if (listener != null) {
580                 listener.onSensorGone();
581             }
582         } else {
583             handleThingUpdateException("sensor", e);
584         }
585     }
586
587     private void handleGroupUpdateException(FullGroup group, Throwable e) {
588         if (e instanceof EntityNotAvailableException) {
589             logger.debug("Error while accessing group: {}", e.getMessage(), e);
590             final HueDeviceDiscoveryService discovery = discoveryService;
591             if (discovery != null) {
592                 discovery.removeGroupDiscovery(group);
593             }
594             final GroupStatusListener listener = groupStatusListeners.get(group.getId());
595             if (listener != null) {
596                 listener.onGroupGone();
597             }
598         } else {
599             handleThingUpdateException("group", e);
600         }
601     }
602
603     private void handleThingUpdateException(String thingType, Throwable e) {
604         if (e instanceof IOException) {
605             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
606         } else if (e instanceof ApiException) {
607             // This should not happen - if it does, it is most likely some bug that should be reported.
608             logger.warn("Error while accessing {}: {}", thingType, e.getMessage());
609         } else if (e instanceof IllegalStateException) {
610             logger.trace("Error while accessing {}: {}", thingType, e.getMessage());
611         }
612     }
613
614     private void startLightPolling() {
615         ScheduledFuture<?> job = lightPollingJob;
616         if (job == null || job.isCancelled()) {
617             long lightPollingInterval;
618             int configPollingInterval = hueBridgeConfig.pollingInterval;
619             if (configPollingInterval < 1) {
620                 lightPollingInterval = TimeUnit.SECONDS.toSeconds(10);
621                 logger.warn("Wrong configuration value for polling interval. Using default value: {}s",
622                         lightPollingInterval);
623             } else {
624                 lightPollingInterval = configPollingInterval;
625             }
626             // Delay the first execution to give a chance to have all light and group things registered
627             lightPollingJob = scheduler.scheduleWithFixedDelay(lightPollingRunnable, 3, lightPollingInterval,
628                     TimeUnit.SECONDS);
629         }
630     }
631
632     private void stopLightPolling() {
633         ScheduledFuture<?> job = lightPollingJob;
634         if (job != null) {
635             job.cancel(true);
636         }
637         lightPollingJob = null;
638     }
639
640     private void startSensorPolling() {
641         ScheduledFuture<?> job = sensorPollingJob;
642         if (job == null || job.isCancelled()) {
643             int configSensorPollingInterval = hueBridgeConfig.sensorPollingInterval;
644             if (configSensorPollingInterval > 0) {
645                 long sensorPollingInterval;
646                 if (configSensorPollingInterval < 50) {
647                     sensorPollingInterval = TimeUnit.MILLISECONDS.toMillis(500);
648                     logger.warn("Wrong configuration value for sensor polling interval. Using default value: {}ms",
649                             sensorPollingInterval);
650                 } else {
651                     sensorPollingInterval = configSensorPollingInterval;
652                 }
653                 // Delay the first execution to give a chance to have all sensor things registered
654                 sensorPollingJob = scheduler.scheduleWithFixedDelay(sensorPollingRunnable, 4000, sensorPollingInterval,
655                         TimeUnit.MILLISECONDS);
656             }
657         }
658     }
659
660     private void stopSensorPolling() {
661         ScheduledFuture<?> job = sensorPollingJob;
662         if (job != null) {
663             job.cancel(true);
664         }
665         sensorPollingJob = null;
666     }
667
668     @Override
669     public void dispose() {
670         logger.debug("Disposing Hue Bridge handler ...");
671         Future<?> job = initJob;
672         if (job != null) {
673             job.cancel(true);
674         }
675         stopLightPolling();
676         stopSensorPolling();
677         if (hueBridge != null) {
678             hueBridge = null;
679         }
680         ServiceRegistration<?> localServiceRegistration = serviceRegistration;
681         if (localServiceRegistration != null) {
682             // remove trustmanager service
683             localServiceRegistration.unregister();
684             serviceRegistration = null;
685         }
686         propertiesInitializedSuccessfully = false;
687     }
688
689     @Override
690     public void initialize() {
691         logger.debug("Initializing Hue Bridge handler ...");
692         hueBridgeConfig = getConfigAs(HueBridgeConfig.class);
693
694         String ip = hueBridgeConfig.ipAddress;
695         if (ip == null || ip.isEmpty()) {
696             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
697                     "@text/offline.conf-error-no-ip-address");
698         } else {
699             if (hueBridge == null) {
700                 hueBridge = new HueBridge(httpClient, ip, hueBridgeConfig.getPort(), hueBridgeConfig.protocol,
701                         scheduler);
702
703                 updateStatus(ThingStatus.UNKNOWN);
704
705                 if (HueBridgeConfig.HTTPS.equals(hueBridgeConfig.protocol)) {
706                     scheduler.submit(() -> {
707                         // register trustmanager service
708                         HueTlsTrustManagerProvider tlsTrustManagerProvider = new HueTlsTrustManagerProvider(
709                                 ip + ":" + hueBridgeConfig.getPort(), hueBridgeConfig.useSelfSignedCertificate);
710
711                         // Check before registering that the PEM certificate can be downloaded
712                         if (tlsTrustManagerProvider.getPEMTrustManager() == null) {
713                             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
714                                     "@text/offline.conf-error-https-connection");
715                             return;
716                         }
717
718                         serviceRegistration = FrameworkUtil.getBundle(getClass()).getBundleContext().registerService(
719                                 TlsTrustManagerProvider.class.getName(), tlsTrustManagerProvider, null);
720
721                         onUpdate();
722                     });
723                 } else {
724                     onUpdate();
725                 }
726             } else {
727                 onUpdate();
728             }
729         }
730     }
731
732     public @Nullable String getUserName() {
733         return hueBridgeConfig == null ? null : hueBridgeConfig.userName;
734     }
735
736     private synchronized void onUpdate() {
737         startLightPolling();
738         startSensorPolling();
739     }
740
741     /**
742      * This method is called whenever the connection to the {@link HueBridge} is lost.
743      */
744     public void onConnectionLost() {
745         logger.debug("Bridge connection lost. Updating thing status to OFFLINE.");
746         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "@text/offline.bridge-connection-lost");
747     }
748
749     /**
750      * This method is called whenever the connection to the {@link HueBridge} is resumed.
751      *
752      * @throws ApiException if the physical device does not support this API call
753      * @throws IOException if the physical device could not be reached
754      */
755     private void onConnectionResumed() throws IOException, ApiException {
756         logger.debug("Bridge connection resumed.");
757
758         if (!propertiesInitializedSuccessfully) {
759             FullConfig fullConfig = hueBridge.getFullConfig();
760             Config config = fullConfig.getConfig();
761             if (config != null) {
762                 Map<String, String> properties = editProperties();
763                 String serialNumber = config.getBridgeId().substring(0, 6) + config.getBridgeId().substring(10);
764                 serialNumber = serialNumber.toLowerCase();
765                 properties.put(PROPERTY_SERIAL_NUMBER, serialNumber);
766                 properties.put(PROPERTY_MODEL_ID, config.getModelId());
767                 properties.put(PROPERTY_MAC_ADDRESS, config.getMACAddress());
768                 properties.put(PROPERTY_FIRMWARE_VERSION, config.getSoftwareVersion());
769                 updateProperties(properties);
770                 propertiesInitializedSuccessfully = true;
771             }
772         }
773     }
774
775     /**
776      * Check USER_NAME config for null. Call onConnectionResumed() otherwise.
777      *
778      * @return True if USER_NAME was not null.
779      * @throws ApiException if the physical device does not support this API call
780      * @throws IOException if the physical device could not be reached
781      */
782     private boolean tryResumeBridgeConnection() throws IOException, ApiException {
783         logger.debug("Connection to Hue Bridge {} established.", hueBridge.getIPAddress());
784         if (hueBridgeConfig.userName == null) {
785             logger.warn(
786                     "User name for Hue Bridge authentication not available in configuration. Setting ThingStatus to OFFLINE.");
787             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
788                     "@text/offline.conf-error-no-username");
789             return false;
790         } else {
791             onConnectionResumed();
792             return true;
793         }
794     }
795
796     /**
797      * This method is called whenever the connection to the {@link HueBridge} is available,
798      * but requests are not allowed due to a missing or invalid authentication.
799      * <p>
800      * If there is a user name available, it attempts to re-authenticate. Otherwise new authentication credentials will
801      * be requested from the bridge.
802      *
803      * @param bridge the Hue Bridge the connection is not authorized
804      * @return returns {@code true} if re-authentication was successful, {@code false} otherwise
805      */
806     public boolean onNotAuthenticated() {
807         if (hueBridge == null) {
808             return false;
809         }
810         String userName = hueBridgeConfig.userName;
811         if (userName == null) {
812             createUser();
813         } else {
814             try {
815                 hueBridge.authenticate(userName);
816                 return true;
817             } catch (ConfigurationException e) {
818                 handleConfigurationFailure(e);
819             } catch (Exception e) {
820                 logger.trace("", e);
821                 handleAuthenticationFailure(e, userName);
822             }
823         }
824         return false;
825     }
826
827     private void createUser() {
828         try {
829             String newUser = createUserOnPhysicalBridge();
830             updateBridgeThingConfiguration(newUser);
831         } catch (LinkButtonException ex) {
832             handleLinkButtonNotPressed(ex);
833         } catch (Exception ex) {
834             handleExceptionWhileCreatingUser(ex);
835         }
836     }
837
838     private String createUserOnPhysicalBridge() throws IOException, ApiException {
839         logger.info("Creating new user on Hue Bridge {} - please press the pairing button on the bridge.",
840                 hueBridgeConfig.ipAddress);
841         String userName = hueBridge.link(DEVICE_TYPE);
842         logger.info("User has been successfully added to Hue Bridge.");
843         return userName;
844     }
845
846     private void updateBridgeThingConfiguration(String userName) {
847         Configuration config = editConfiguration();
848         config.put(USER_NAME, userName);
849         try {
850             updateConfiguration(config);
851             logger.debug("Updated configuration parameter '{}'", USER_NAME);
852             hueBridgeConfig = getConfigAs(HueBridgeConfig.class);
853         } catch (IllegalStateException e) {
854             logger.trace("Configuration update failed.", e);
855             logger.warn("Unable to update configuration of Hue Bridge.");
856             logger.warn("Please configure the user name manually.");
857         }
858     }
859
860     private void handleConfigurationFailure(ConfigurationException ex) {
861         logger.warn(
862                 "Invalid certificate for secured connection. You might want to enable the \"Use Self-Signed Certificate\" configuration.");
863         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, ex.getRawMessage());
864     }
865
866     private void handleAuthenticationFailure(Exception ex, String userName) {
867         logger.warn("User is not authenticated on Hue Bridge {}", hueBridgeConfig.ipAddress);
868         logger.warn("Please configure a valid user or remove user from configuration to generate a new one.");
869         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
870                 "@text/offline.conf-error-invalid-username");
871     }
872
873     private void handleLinkButtonNotPressed(LinkButtonException ex) {
874         logger.debug("Failed creating new user on Hue Bridge: {}", ex.getMessage());
875         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
876                 "@text/offline.conf-error-press-pairing-button");
877     }
878
879     private void handleExceptionWhileCreatingUser(Exception ex) {
880         logger.warn("Failed creating new user on Hue Bridge", ex);
881         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
882                 "@text/offline.conf-error-creation-username");
883     }
884
885     @Override
886     public boolean registerDiscoveryListener(HueDeviceDiscoveryService listener) {
887         if (discoveryService == null) {
888             discoveryService = listener;
889             getFullLights().forEach(listener::addLightDiscovery);
890             getFullSensors().forEach(listener::addSensorDiscovery);
891             getFullGroups().forEach(listener::addGroupDiscovery);
892             return true;
893         }
894
895         return false;
896     }
897
898     @Override
899     public boolean unregisterDiscoveryListener() {
900         if (discoveryService != null) {
901             discoveryService = null;
902             return true;
903         }
904
905         return false;
906     }
907
908     @Override
909     public boolean registerLightStatusListener(LightStatusListener lightStatusListener) {
910         final String lightId = lightStatusListener.getLightId();
911         if (!lightStatusListeners.containsKey(lightId)) {
912             lightStatusListeners.put(lightId, lightStatusListener);
913             final FullLight lastLightState = lastLightStates.get(lightId);
914             if (lastLightState != null) {
915                 lightStatusListener.onLightAdded(lastLightState);
916             }
917
918             return true;
919         }
920         return false;
921     }
922
923     @Override
924     public boolean unregisterLightStatusListener(LightStatusListener lightStatusListener) {
925         return lightStatusListeners.remove(lightStatusListener.getLightId()) != null;
926     }
927
928     @Override
929     public boolean registerSensorStatusListener(SensorStatusListener sensorStatusListener) {
930         final String sensorId = sensorStatusListener.getSensorId();
931         if (!sensorStatusListeners.containsKey(sensorId)) {
932             sensorStatusListeners.put(sensorId, sensorStatusListener);
933             final FullSensor lastSensorState = lastSensorStates.get(sensorId);
934             if (lastSensorState != null) {
935                 sensorStatusListener.onSensorAdded(lastSensorState);
936             }
937             return true;
938         }
939
940         return false;
941     }
942
943     @Override
944     public boolean unregisterSensorStatusListener(SensorStatusListener sensorStatusListener) {
945         return sensorStatusListeners.remove(sensorStatusListener.getSensorId()) != null;
946     }
947
948     @Override
949     public boolean registerGroupStatusListener(GroupStatusListener groupStatusListener) {
950         final String groupId = groupStatusListener.getGroupId();
951         if (!groupStatusListeners.containsKey(groupId)) {
952             groupStatusListeners.put(groupId, groupStatusListener);
953             final FullGroup lastGroupState = lastGroupStates.get(groupId);
954             if (lastGroupState != null) {
955                 groupStatusListener.onGroupAdded(lastGroupState);
956                 if (!lastScenes.isEmpty()) {
957                     groupStatusListener.onScenesUpdated(lastScenes);
958                 }
959             }
960             return true;
961         }
962
963         return false;
964     }
965
966     @Override
967     public boolean unregisterGroupStatusListener(GroupStatusListener groupStatusListener) {
968         return groupStatusListeners.remove(groupStatusListener.getGroupId()) != null;
969     }
970
971     /**
972      * Recall scene to all lights that belong to the scene.
973      *
974      * @param id the ID of the scene to activate
975      */
976     @Override
977     public void recallScene(String id) {
978         if (hueBridge != null) {
979             hueBridge.recallScene(id).thenAccept(result -> {
980                 try {
981                     hueBridge.handleErrors(result);
982                 } catch (Exception e) {
983                     logger.debug("Error while recalling scene: {}", e.getMessage());
984                 }
985             }).exceptionally(e -> {
986                 logger.debug("Error while recalling scene: {}", e.getMessage());
987                 return null;
988             });
989         } else {
990             logger.debug("No bridge connected or selected. Cannot activate scene.");
991         }
992     }
993
994     @Override
995     public @Nullable FullLight getLightById(String lightId) {
996         return lastLightStates.get(lightId);
997     }
998
999     @Override
1000     public @Nullable FullSensor getSensorById(String sensorId) {
1001         return lastSensorStates.get(sensorId);
1002     }
1003
1004     @Override
1005     public @Nullable FullGroup getGroupById(String groupId) {
1006         return lastGroupStates.get(groupId);
1007     }
1008
1009     public List<FullLight> getFullLights() {
1010         List<FullLight> ret = withReAuthentication("search for new lights", () -> {
1011             return hueBridge.getFullLights();
1012         });
1013         return ret != null ? ret : List.of();
1014     }
1015
1016     public List<FullSensor> getFullSensors() {
1017         List<FullSensor> ret = withReAuthentication("search for new sensors", () -> {
1018             return hueBridge.getSensors();
1019         });
1020         return ret != null ? ret : List.of();
1021     }
1022
1023     public List<FullGroup> getFullGroups() {
1024         List<FullGroup> ret = withReAuthentication("search for new groups", () -> {
1025             return hueBridge.getGroups();
1026         });
1027         return ret != null ? ret : List.of();
1028     }
1029
1030     public void startSearch() {
1031         withReAuthentication("start search mode", () -> {
1032             hueBridge.startSearch();
1033             return null;
1034         });
1035     }
1036
1037     public void startSearch(List<String> serialNumbers) {
1038         withReAuthentication("start search mode", () -> {
1039             hueBridge.startSearch(serialNumbers);
1040             return null;
1041         });
1042     }
1043
1044     private @Nullable <T> T withReAuthentication(String taskDescription, Callable<T> runnable) {
1045         if (hueBridge != null) {
1046             try {
1047                 try {
1048                     return runnable.call();
1049                 } catch (UnauthorizedException | IllegalStateException e) {
1050                     lastBridgeConnectionState = false;
1051                     if (onNotAuthenticated()) {
1052                         return runnable.call();
1053                     }
1054                 }
1055             } catch (Exception e) {
1056                 logger.debug("Bridge cannot {}.", taskDescription, e);
1057             }
1058         }
1059         return null;
1060     }
1061
1062     private void notifyGroupSceneUpdate(List<Scene> scenes) {
1063         groupStatusListeners.forEach((groupId, listener) -> listener.onScenesUpdated(scenes));
1064     }
1065
1066     public List<String> listScenesForConsole() {
1067         return consoleScenesList;
1068     }
1069
1070     @Override
1071     public Collection<ConfigStatusMessage> getConfigStatus() {
1072         // The bridge IP address to be used for checks
1073         // Check whether an IP address is provided
1074         hueBridgeConfig = getConfigAs(HueBridgeConfig.class);
1075
1076         String ip = hueBridgeConfig.ipAddress;
1077         if (ip == null || ip.isEmpty()) {
1078             return List.of(ConfigStatusMessage.Builder.error(HOST).withMessageKeySuffix(IP_ADDRESS_MISSING)
1079                     .withArguments(HOST).build());
1080         } else {
1081             return List.of();
1082         }
1083     }
1084
1085     public TranslationProvider getI18nProvider() {
1086         return i18nProvider;
1087     }
1088
1089     public LocaleProvider getLocaleProvider() {
1090         return localeProvider;
1091     }
1092 }