]> git.basschouten.com Git - openhab-addons.git/blob
30991dd198e08faf140ed54ca80cd7a0c03e654c
[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      * @return returns {@code true} if re-authentication was successful, {@code false} otherwise
803      */
804     public boolean onNotAuthenticated() {
805         if (hueBridge == null) {
806             return false;
807         }
808         String userName = hueBridgeConfig.userName;
809         if (userName == null) {
810             createUser();
811         } else {
812             try {
813                 hueBridge.authenticate(userName);
814                 return true;
815             } catch (ConfigurationException e) {
816                 handleConfigurationFailure(e);
817             } catch (Exception e) {
818                 logger.trace("", e);
819                 handleAuthenticationFailure(e, userName);
820             }
821         }
822         return false;
823     }
824
825     private void createUser() {
826         try {
827             String newUser = createUserOnPhysicalBridge();
828             updateBridgeThingConfiguration(newUser);
829         } catch (LinkButtonException ex) {
830             handleLinkButtonNotPressed(ex);
831         } catch (Exception ex) {
832             handleExceptionWhileCreatingUser(ex);
833         }
834     }
835
836     private String createUserOnPhysicalBridge() throws IOException, ApiException {
837         logger.info("Creating new user on Hue Bridge {} - please press the pairing button on the bridge.",
838                 hueBridgeConfig.ipAddress);
839         String userName = hueBridge.link(DEVICE_TYPE);
840         logger.info("User has been successfully added to Hue Bridge.");
841         return userName;
842     }
843
844     private void updateBridgeThingConfiguration(String userName) {
845         Configuration config = editConfiguration();
846         config.put(USER_NAME, userName);
847         try {
848             updateConfiguration(config);
849             logger.debug("Updated configuration parameter '{}'", USER_NAME);
850             hueBridgeConfig = getConfigAs(HueBridgeConfig.class);
851         } catch (IllegalStateException e) {
852             logger.trace("Configuration update failed.", e);
853             logger.warn("Unable to update configuration of Hue Bridge.");
854             logger.warn("Please configure the user name manually.");
855         }
856     }
857
858     private void handleConfigurationFailure(ConfigurationException ex) {
859         logger.warn(
860                 "Invalid certificate for secured connection. You might want to enable the \"Use Self-Signed Certificate\" configuration.");
861         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, ex.getRawMessage());
862     }
863
864     private void handleAuthenticationFailure(Exception ex, String userName) {
865         logger.warn("User is not authenticated on Hue Bridge {}", hueBridgeConfig.ipAddress);
866         logger.warn("Please configure a valid user or remove user from configuration to generate a new one.");
867         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
868                 "@text/offline.conf-error-invalid-username");
869     }
870
871     private void handleLinkButtonNotPressed(LinkButtonException ex) {
872         logger.debug("Failed creating new user on Hue Bridge: {}", ex.getMessage());
873         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
874                 "@text/offline.conf-error-press-pairing-button");
875     }
876
877     private void handleExceptionWhileCreatingUser(Exception ex) {
878         logger.warn("Failed creating new user on Hue Bridge", ex);
879         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
880                 "@text/offline.conf-error-creation-username");
881     }
882
883     @Override
884     public boolean registerDiscoveryListener(HueDeviceDiscoveryService listener) {
885         if (discoveryService == null) {
886             discoveryService = listener;
887             getFullLights().forEach(listener::addLightDiscovery);
888             getFullSensors().forEach(listener::addSensorDiscovery);
889             getFullGroups().forEach(listener::addGroupDiscovery);
890             return true;
891         }
892
893         return false;
894     }
895
896     @Override
897     public boolean unregisterDiscoveryListener() {
898         if (discoveryService != null) {
899             discoveryService = null;
900             return true;
901         }
902
903         return false;
904     }
905
906     @Override
907     public boolean registerLightStatusListener(LightStatusListener lightStatusListener) {
908         final String lightId = lightStatusListener.getLightId();
909         if (!lightStatusListeners.containsKey(lightId)) {
910             lightStatusListeners.put(lightId, lightStatusListener);
911             final FullLight lastLightState = lastLightStates.get(lightId);
912             if (lastLightState != null) {
913                 lightStatusListener.onLightAdded(lastLightState);
914             }
915
916             return true;
917         }
918         return false;
919     }
920
921     @Override
922     public boolean unregisterLightStatusListener(LightStatusListener lightStatusListener) {
923         return lightStatusListeners.remove(lightStatusListener.getLightId()) != null;
924     }
925
926     @Override
927     public boolean registerSensorStatusListener(SensorStatusListener sensorStatusListener) {
928         final String sensorId = sensorStatusListener.getSensorId();
929         if (!sensorStatusListeners.containsKey(sensorId)) {
930             sensorStatusListeners.put(sensorId, sensorStatusListener);
931             final FullSensor lastSensorState = lastSensorStates.get(sensorId);
932             if (lastSensorState != null) {
933                 sensorStatusListener.onSensorAdded(lastSensorState);
934             }
935             return true;
936         }
937
938         return false;
939     }
940
941     @Override
942     public boolean unregisterSensorStatusListener(SensorStatusListener sensorStatusListener) {
943         return sensorStatusListeners.remove(sensorStatusListener.getSensorId()) != null;
944     }
945
946     @Override
947     public boolean registerGroupStatusListener(GroupStatusListener groupStatusListener) {
948         final String groupId = groupStatusListener.getGroupId();
949         if (!groupStatusListeners.containsKey(groupId)) {
950             groupStatusListeners.put(groupId, groupStatusListener);
951             final FullGroup lastGroupState = lastGroupStates.get(groupId);
952             if (lastGroupState != null) {
953                 groupStatusListener.onGroupAdded(lastGroupState);
954                 if (!lastScenes.isEmpty()) {
955                     groupStatusListener.onScenesUpdated(lastScenes);
956                 }
957             }
958             return true;
959         }
960
961         return false;
962     }
963
964     @Override
965     public boolean unregisterGroupStatusListener(GroupStatusListener groupStatusListener) {
966         return groupStatusListeners.remove(groupStatusListener.getGroupId()) != null;
967     }
968
969     /**
970      * Recall scene to all lights that belong to the scene.
971      *
972      * @param id the ID of the scene to activate
973      */
974     @Override
975     public void recallScene(String id) {
976         if (hueBridge != null) {
977             hueBridge.recallScene(id).thenAccept(result -> {
978                 try {
979                     hueBridge.handleErrors(result);
980                 } catch (Exception e) {
981                     logger.debug("Error while recalling scene: {}", e.getMessage());
982                 }
983             }).exceptionally(e -> {
984                 logger.debug("Error while recalling scene: {}", e.getMessage());
985                 return null;
986             });
987         } else {
988             logger.debug("No bridge connected or selected. Cannot activate scene.");
989         }
990     }
991
992     @Override
993     public @Nullable FullLight getLightById(String lightId) {
994         return lastLightStates.get(lightId);
995     }
996
997     @Override
998     public @Nullable FullSensor getSensorById(String sensorId) {
999         return lastSensorStates.get(sensorId);
1000     }
1001
1002     @Override
1003     public @Nullable FullGroup getGroupById(String groupId) {
1004         return lastGroupStates.get(groupId);
1005     }
1006
1007     public List<FullLight> getFullLights() {
1008         List<FullLight> ret = withReAuthentication("search for new lights", () -> {
1009             return hueBridge.getFullLights();
1010         });
1011         return ret != null ? ret : List.of();
1012     }
1013
1014     public List<FullSensor> getFullSensors() {
1015         List<FullSensor> ret = withReAuthentication("search for new sensors", () -> {
1016             return hueBridge.getSensors();
1017         });
1018         return ret != null ? ret : List.of();
1019     }
1020
1021     public List<FullGroup> getFullGroups() {
1022         List<FullGroup> ret = withReAuthentication("search for new groups", () -> {
1023             return hueBridge.getGroups();
1024         });
1025         return ret != null ? ret : List.of();
1026     }
1027
1028     public void startSearch() {
1029         withReAuthentication("start search mode", () -> {
1030             hueBridge.startSearch();
1031             return null;
1032         });
1033     }
1034
1035     public void startSearch(List<String> serialNumbers) {
1036         withReAuthentication("start search mode", () -> {
1037             hueBridge.startSearch(serialNumbers);
1038             return null;
1039         });
1040     }
1041
1042     private @Nullable <T> T withReAuthentication(String taskDescription, Callable<T> runnable) {
1043         if (hueBridge != null) {
1044             try {
1045                 try {
1046                     return runnable.call();
1047                 } catch (UnauthorizedException | IllegalStateException e) {
1048                     lastBridgeConnectionState = false;
1049                     if (onNotAuthenticated()) {
1050                         return runnable.call();
1051                     }
1052                 }
1053             } catch (Exception e) {
1054                 logger.debug("Bridge cannot {}.", taskDescription, e);
1055             }
1056         }
1057         return null;
1058     }
1059
1060     private void notifyGroupSceneUpdate(List<Scene> scenes) {
1061         groupStatusListeners.forEach((groupId, listener) -> listener.onScenesUpdated(scenes));
1062     }
1063
1064     public List<String> listScenesForConsole() {
1065         return consoleScenesList;
1066     }
1067
1068     @Override
1069     public Collection<ConfigStatusMessage> getConfigStatus() {
1070         // The bridge IP address to be used for checks
1071         // Check whether an IP address is provided
1072         hueBridgeConfig = getConfigAs(HueBridgeConfig.class);
1073
1074         String ip = hueBridgeConfig.ipAddress;
1075         if (ip == null || ip.isEmpty()) {
1076             return List.of(ConfigStatusMessage.Builder.error(HOST).withMessageKeySuffix(IP_ADDRESS_MISSING)
1077                     .withArguments(HOST).build());
1078         } else {
1079             return List.of();
1080         }
1081     }
1082
1083     public TranslationProvider getI18nProvider() {
1084         return i18nProvider;
1085     }
1086
1087     public LocaleProvider getLocaleProvider() {
1088         return localeProvider;
1089     }
1090 }