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