]> git.basschouten.com Git - openhab-addons.git/blob
2891cf9e4ea7b18f23ea89a336fe1a44b089ab95
[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
17 import java.io.IOException;
18 import java.util.Collection;
19 import java.util.HashMap;
20 import java.util.List;
21 import java.util.Map;
22 import java.util.Objects;
23 import java.util.Optional;
24 import java.util.Set;
25 import java.util.concurrent.ConcurrentHashMap;
26 import java.util.concurrent.Future;
27 import java.util.concurrent.ScheduledExecutorService;
28 import java.util.concurrent.ScheduledFuture;
29 import java.util.concurrent.TimeUnit;
30
31 import org.eclipse.jdt.annotation.NonNullByDefault;
32 import org.eclipse.jdt.annotation.Nullable;
33 import org.openhab.binding.hue.internal.config.Clip2BridgeConfig;
34 import org.openhab.binding.hue.internal.connection.Clip2Bridge;
35 import org.openhab.binding.hue.internal.connection.HueTlsTrustManagerProvider;
36 import org.openhab.binding.hue.internal.discovery.Clip2ThingDiscoveryService;
37 import org.openhab.binding.hue.internal.dto.clip2.MetaData;
38 import org.openhab.binding.hue.internal.dto.clip2.ProductData;
39 import org.openhab.binding.hue.internal.dto.clip2.Resource;
40 import org.openhab.binding.hue.internal.dto.clip2.ResourceReference;
41 import org.openhab.binding.hue.internal.dto.clip2.Resources;
42 import org.openhab.binding.hue.internal.dto.clip2.enums.Archetype;
43 import org.openhab.binding.hue.internal.dto.clip2.enums.ResourceType;
44 import org.openhab.binding.hue.internal.exceptions.ApiException;
45 import org.openhab.binding.hue.internal.exceptions.AssetNotLoadedException;
46 import org.openhab.binding.hue.internal.exceptions.HttpUnauthorizedException;
47 import org.openhab.core.config.core.Configuration;
48 import org.openhab.core.i18n.LocaleProvider;
49 import org.openhab.core.i18n.TranslationProvider;
50 import org.openhab.core.io.net.http.HttpClientFactory;
51 import org.openhab.core.io.net.http.TlsTrustManagerProvider;
52 import org.openhab.core.thing.Bridge;
53 import org.openhab.core.thing.ChannelUID;
54 import org.openhab.core.thing.Thing;
55 import org.openhab.core.thing.ThingRegistry;
56 import org.openhab.core.thing.ThingStatus;
57 import org.openhab.core.thing.ThingStatusDetail;
58 import org.openhab.core.thing.ThingTypeUID;
59 import org.openhab.core.thing.ThingUID;
60 import org.openhab.core.thing.binding.BaseBridgeHandler;
61 import org.openhab.core.thing.binding.ThingHandler;
62 import org.openhab.core.thing.binding.ThingHandlerService;
63 import org.openhab.core.thing.binding.builder.BridgeBuilder;
64 import org.openhab.core.types.Command;
65 import org.openhab.core.types.RefreshType;
66 import org.osgi.framework.Bundle;
67 import org.osgi.framework.FrameworkUtil;
68 import org.osgi.framework.ServiceRegistration;
69 import org.slf4j.Logger;
70 import org.slf4j.LoggerFactory;
71
72 /**
73  * Bridge handler for a CLIP 2 bridge. It communicates with the bridge via CLIP 2 end points, and reads and writes API
74  * V2 resource objects. It also subscribes to the server's SSE event stream, and receives SSE events from it.
75  *
76  * @author Andrew Fiddian-Green - Initial contribution.
77  */
78 @NonNullByDefault
79 public class Clip2BridgeHandler extends BaseBridgeHandler {
80
81     public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Set.of(THING_TYPE_BRIDGE_API2);
82
83     private static final int FAST_SCHEDULE_MILLI_SECONDS = 500;
84     private static final int APPLICATION_KEY_MAX_TRIES = 600; // i.e. 300 seconds, 5 minutes
85     private static final int RECONNECT_DELAY_SECONDS = 10;
86     private static final int RECONNECT_MAX_TRIES = 5;
87
88     private static final ResourceReference DEVICE = new ResourceReference().setType(ResourceType.DEVICE);
89     private static final ResourceReference ROOM = new ResourceReference().setType(ResourceType.ROOM);
90     private static final ResourceReference ZONE = new ResourceReference().setType(ResourceType.ZONE);
91     private static final ResourceReference BRIDGE = new ResourceReference().setType(ResourceType.BRIDGE);
92     private static final ResourceReference BRIDGE_HOME = new ResourceReference().setType(ResourceType.BRIDGE_HOME);
93     private static final ResourceReference SCENE = new ResourceReference().setType(ResourceType.SCENE);
94     private static final ResourceReference SMART_SCENE = new ResourceReference().setType(ResourceType.SMART_SCENE);
95
96     /**
97      * List of resource references that need to be mass down loaded.
98      * NOTE: the SCENE resources must be mass down loaded first!
99      */
100     private static final List<ResourceReference> MASS_DOWNLOAD_RESOURCE_REFERENCES = List.of(SCENE, DEVICE, ROOM, ZONE);
101
102     private final Logger logger = LoggerFactory.getLogger(Clip2BridgeHandler.class);
103
104     private final HttpClientFactory httpClientFactory;
105     private final ThingRegistry thingRegistry;
106     private final Bundle bundle;
107     private final LocaleProvider localeProvider;
108     private final TranslationProvider translationProvider;
109
110     private @Nullable Clip2Bridge clip2Bridge;
111     private @Nullable ServiceRegistration<?> trustManagerRegistration;
112     private @Nullable Clip2ThingDiscoveryService discoveryService;
113
114     private @Nullable Future<?> checkConnectionTask;
115     private @Nullable Future<?> updateOnlineStateTask;
116     private @Nullable ScheduledFuture<?> scheduledUpdateTask;
117     private Map<Integer, Future<?>> resourcesEventTasks = new ConcurrentHashMap<>();
118
119     private boolean assetsLoaded;
120     private int applKeyRetriesRemaining;
121     private int connectRetriesRemaining;
122
123     public Clip2BridgeHandler(Bridge bridge, HttpClientFactory httpClientFactory, ThingRegistry thingRegistry,
124             LocaleProvider localeProvider, TranslationProvider translationProvider) {
125         super(bridge);
126         this.httpClientFactory = httpClientFactory;
127         this.thingRegistry = thingRegistry;
128         this.bundle = FrameworkUtil.getBundle(getClass());
129         this.localeProvider = localeProvider;
130         this.translationProvider = translationProvider;
131     }
132
133     /**
134      * Cancel the given task.
135      *
136      * @param cancelTask the task to be cancelled (may be null)
137      * @param mayInterrupt allows cancel() to interrupt the thread.
138      */
139     private void cancelTask(@Nullable Future<?> cancelTask, boolean mayInterrupt) {
140         if (Objects.nonNull(cancelTask)) {
141             cancelTask.cancel(mayInterrupt);
142         }
143     }
144
145     /**
146      * Check if assets are loaded.
147      *
148      * @throws AssetNotLoadedException if assets not loaded.
149      */
150     private void checkAssetsLoaded() throws AssetNotLoadedException {
151         if (!assetsLoaded) {
152             throw new AssetNotLoadedException("Assets not loaded");
153         }
154     }
155
156     /**
157      * Try to connect and set the online status accordingly. If the connection attempt throws an
158      * HttpUnAuthorizedException then try to register the existing application key, or create a new one, with the hub.
159      * If the connection attempt throws an ApiException then set the thing status to offline. This method is called on a
160      * scheduler thread, which reschedules itself repeatedly until the thing is shutdown.
161      */
162     private synchronized void checkConnection() {
163         logger.debug("checkConnection()");
164
165         boolean retryApplicationKey = false;
166         boolean retryConnection = false;
167
168         try {
169             checkAssetsLoaded();
170             getClip2Bridge().testConnectionState();
171             updateSelf(); // go online
172         } catch (HttpUnauthorizedException unauthorizedException) {
173             logger.debug("checkConnection() {}", unauthorizedException.getMessage(), unauthorizedException);
174             if (applKeyRetriesRemaining > 0) {
175                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
176                         "@text/offline.api2.conf-error.press-pairing-button");
177                 try {
178                     registerApplicationKey();
179                     retryApplicationKey = true;
180                 } catch (HttpUnauthorizedException e) {
181                     retryApplicationKey = true;
182                 } catch (ApiException e) {
183                     setStatusOfflineWithCommunicationError(e);
184                 } catch (IllegalStateException e) {
185                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
186                             "@text/offline.api2.conf-error.read-only");
187                 } catch (AssetNotLoadedException e) {
188                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
189                             "@text/offline.api2.conf-error.assets-not-loaded");
190                 } catch (InterruptedException e) {
191                     return;
192                 }
193             } else {
194                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
195                         "@text/offline.api2.conf-error.not-authorized");
196             }
197         } catch (ApiException e) {
198             logger.debug("checkConnection() {}", e.getMessage(), e);
199             setStatusOfflineWithCommunicationError(e);
200             retryConnection = connectRetriesRemaining > 0;
201         } catch (AssetNotLoadedException e) {
202             logger.debug("checkConnection() {}", e.getMessage(), e);
203             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
204                     "@text/offline.api2.conf-error.assets-not-loaded");
205         } catch (InterruptedException e) {
206             return;
207         }
208
209         int milliSeconds;
210         if (retryApplicationKey) {
211             // short delay used during attempts to create or validate an application key
212             milliSeconds = FAST_SCHEDULE_MILLI_SECONDS;
213             applKeyRetriesRemaining--;
214         } else {
215             // default delay, set via configuration parameter, used as heart-beat 'just-in-case'
216             Clip2BridgeConfig config = getConfigAs(Clip2BridgeConfig.class);
217             milliSeconds = config.checkMinutes * 60000;
218             if (retryConnection) {
219                 // exponential back off delay used during attempts to reconnect
220                 int backOffDelay = 60000 * (int) Math.pow(2, RECONNECT_MAX_TRIES - connectRetriesRemaining);
221                 milliSeconds = Math.min(milliSeconds, backOffDelay);
222                 connectRetriesRemaining--;
223             }
224         }
225
226         // this method schedules itself to be called again in a loop..
227         cancelTask(checkConnectionTask, false);
228         checkConnectionTask = scheduler.schedule(() -> checkConnection(), milliSeconds, TimeUnit.MILLISECONDS);
229     }
230
231     private void setStatusOfflineWithCommunicationError(Exception e) {
232         Throwable cause = e.getCause();
233         String causeMessage = cause == null ? null : cause.getMessage();
234         if (causeMessage == null || causeMessage.isEmpty()) {
235             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
236                     "@text/offline.api2.comm-error.exception [\"" + e.getMessage() + "\"]");
237         } else {
238             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
239                     "@text/offline.api2.comm-error.exception [\"" + e.getMessage() + " -> " + causeMessage + "\"]");
240         }
241     }
242
243     /**
244      * If a child thing has been added, and the bridge is online, update the child's data.
245      */
246     public void childInitialized() {
247         if (thing.getStatus() == ThingStatus.ONLINE) {
248             updateThingsScheduled(5000);
249         }
250     }
251
252     @Override
253     public void dispose() {
254         if (assetsLoaded) {
255             disposeAssets();
256         }
257     }
258
259     /**
260      * Dispose the bridge handler's assets. Called from dispose() on a thread, so that dispose() itself can complete
261      * faster.
262      */
263     private void disposeAssets() {
264         logger.debug("disposeAssets() {}", this);
265         synchronized (this) {
266             assetsLoaded = false;
267             cancelTask(checkConnectionTask, true);
268             cancelTask(updateOnlineStateTask, true);
269             cancelTask(scheduledUpdateTask, true);
270             checkConnectionTask = null;
271             updateOnlineStateTask = null;
272             scheduledUpdateTask = null;
273             synchronized (resourcesEventTasks) {
274                 resourcesEventTasks.values().forEach(task -> cancelTask(task, true));
275                 resourcesEventTasks.clear();
276             }
277             ServiceRegistration<?> registration = trustManagerRegistration;
278             if (Objects.nonNull(registration)) {
279                 registration.unregister();
280                 trustManagerRegistration = null;
281             }
282             Clip2Bridge bridge = clip2Bridge;
283             if (Objects.nonNull(bridge)) {
284                 bridge.close();
285                 clip2Bridge = null;
286             }
287             Clip2ThingDiscoveryService disco = discoveryService;
288             if (Objects.nonNull(disco)) {
289                 disco.abortScan();
290             }
291         }
292     }
293
294     /**
295      * Return the application key for the console app.
296      *
297      * @return the application key.
298      */
299     public String getApplicationKey() {
300         Clip2BridgeConfig config = getConfigAs(Clip2BridgeConfig.class);
301         return config.applicationKey;
302     }
303
304     /**
305      * Get the Clip2Bridge connection and throw an exception if it is null.
306      *
307      * @return the Clip2Bridge.
308      * @throws AssetNotLoadedException if the Clip2Bridge is null.
309      */
310     private Clip2Bridge getClip2Bridge() throws AssetNotLoadedException {
311         Clip2Bridge clip2Bridge = this.clip2Bridge;
312         if (Objects.nonNull(clip2Bridge)) {
313             return clip2Bridge;
314         }
315         throw new AssetNotLoadedException("Clip2Bridge is null");
316     }
317
318     /**
319      * Return the IP address for the console app.
320      *
321      * @return the IP address.
322      */
323     public String getIpAddress() {
324         Clip2BridgeConfig config = getConfigAs(Clip2BridgeConfig.class);
325         return config.ipAddress;
326     }
327
328     /**
329      * Get the v1 legacy Hue bridge (if any) which has the same IP address as this.
330      *
331      * @return Optional result containing the legacy bridge (if any found).
332      */
333     public Optional<Thing> getLegacyBridge() {
334         String ipAddress = getIpAddress();
335         return Objects.nonNull(ipAddress)
336                 ? thingRegistry.getAll().stream()
337                         .filter(thing -> thing.getThingTypeUID().equals(THING_TYPE_BRIDGE)
338                                 && ipAddress.equals(thing.getConfiguration().get("ipAddress")))
339                         .findFirst()
340                 : Optional.empty();
341     }
342
343     /**
344      * Get the v1 legacy Hue thing (if any) which has a Bridge having the same IP address as this, and an ID that
345      * matches the given parameter.
346      *
347      * @param targetIdV1 the idV1 attribute to match.
348      * @return Optional result containing the legacy thing (if found).
349      */
350     public Optional<Thing> getLegacyThing(String targetIdV1) {
351         Optional<Thing> legacyBridge = getLegacyBridge();
352         if (legacyBridge.isEmpty()) {
353             return Optional.empty();
354         }
355
356         String config;
357         if (targetIdV1.startsWith("/lights/")) {
358             config = LIGHT_ID;
359         } else if (targetIdV1.startsWith("/sensors/")) {
360             config = SENSOR_ID;
361         } else if (targetIdV1.startsWith("/groups/")) {
362             config = GROUP_ID;
363         } else {
364             return Optional.empty();
365         }
366
367         ThingUID legacyBridgeUID = legacyBridge.get().getUID();
368         return thingRegistry.getAll().stream() //
369                 .filter(thing -> legacyBridgeUID.equals(thing.getBridgeUID())
370                         && V1_THING_TYPE_UIDS.contains(thing.getThingTypeUID())) //
371                 .filter(thing -> {
372                     Object id = thing.getConfiguration().get(config);
373                     return (id instanceof String) && targetIdV1.endsWith("/" + (String) id);
374                 }).findFirst();
375     }
376
377     /**
378      * Return a localized text.
379      *
380      * @param key the i18n text key.
381      * @param arguments for parameterized translation.
382      * @return the localized text.
383      */
384     public String getLocalizedText(String key, @Nullable Object @Nullable... arguments) {
385         String result = translationProvider.getText(bundle, key, key, localeProvider.getLocale(), arguments);
386         return Objects.nonNull(result) ? result : key;
387     }
388
389     /**
390      * Execute an HTTP GET for a resources reference object from the server.
391      *
392      * @param reference containing the resourceType and (optionally) the resourceId of the resource to get. If the
393      *            resourceId is null then all resources of the given type are returned.
394      * @return the resource, or null if something fails.
395      * @throws ApiException if a communication error occurred.
396      * @throws AssetNotLoadedException if one of the assets is not loaded.
397      * @throws InterruptedException
398      */
399     public Resources getResources(ResourceReference reference)
400             throws ApiException, AssetNotLoadedException, InterruptedException {
401         logger.debug("getResources() {}", reference);
402         checkAssetsLoaded();
403         return getClip2Bridge().getResources(reference);
404     }
405
406     /**
407      * Getter for the scheduler.
408      *
409      * @return the scheduler.
410      */
411     public ScheduledExecutorService getScheduler() {
412         return scheduler;
413     }
414
415     @Override
416     public Collection<Class<? extends ThingHandlerService>> getServices() {
417         return Set.of(Clip2ThingDiscoveryService.class);
418     }
419
420     @Override
421     public void handleCommand(ChannelUID channelUID, Command command) {
422         if (RefreshType.REFRESH.equals(command)) {
423             return;
424         }
425         logger.warn("Bridge thing '{}' has no channels, only REFRESH command supported.", thing.getUID());
426     }
427
428     @Override
429     public void initialize() {
430         updateThingFromLegacy();
431         updateStatus(ThingStatus.UNKNOWN);
432         applKeyRetriesRemaining = APPLICATION_KEY_MAX_TRIES;
433         connectRetriesRemaining = RECONNECT_MAX_TRIES;
434         initializeAssets();
435     }
436
437     /**
438      * Initialize the bridge handler's assets.
439      */
440     private void initializeAssets() {
441         logger.debug("initializeAssets() {}", this);
442         synchronized (this) {
443             Clip2BridgeConfig config = getConfigAs(Clip2BridgeConfig.class);
444
445             String ipAddress = config.ipAddress;
446             if (ipAddress.isBlank()) {
447                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
448                         "@text/offline.conf-error-no-ip-address");
449                 return;
450             }
451
452             try {
453                 if (!Clip2Bridge.isClip2Supported(ipAddress)) {
454                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
455                             "@text/offline.api2.conf-error.clip2-not-supported");
456                     return;
457                 }
458             } catch (IOException e) {
459                 logger.trace("initializeAssets() communication error on '{}'", ipAddress, e);
460                 setStatusOfflineWithCommunicationError(e);
461                 return;
462             }
463
464             HueTlsTrustManagerProvider trustManagerProvider = new HueTlsTrustManagerProvider(ipAddress + ":443",
465                     config.useSelfSignedCertificate);
466
467             if (Objects.isNull(trustManagerProvider.getPEMTrustManager())) {
468                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
469                         "@text/offline.api2.conf-error.certificate-load");
470                 return;
471             }
472
473             trustManagerRegistration = FrameworkUtil.getBundle(getClass()).getBundleContext()
474                     .registerService(TlsTrustManagerProvider.class.getName(), trustManagerProvider, null);
475
476             String applicationKey = config.applicationKey;
477             applicationKey = Objects.nonNull(applicationKey) ? applicationKey : "";
478
479             try {
480                 clip2Bridge = new Clip2Bridge(httpClientFactory, this, ipAddress, applicationKey);
481             } catch (ApiException e) {
482                 logger.trace("initializeAssets() communication error on '{}'", ipAddress, e);
483                 setStatusOfflineWithCommunicationError(e);
484                 return;
485             }
486
487             assetsLoaded = true;
488         }
489         cancelTask(checkConnectionTask, false);
490         checkConnectionTask = scheduler.submit(() -> checkConnection());
491     }
492
493     /**
494      * Called when the connection goes offline. Schedule a reconnection.
495      */
496     public void onConnectionOffline() {
497         if (assetsLoaded) {
498             cancelTask(checkConnectionTask, false);
499             checkConnectionTask = scheduler.schedule(() -> checkConnection(), RECONNECT_DELAY_SECONDS,
500                     TimeUnit.SECONDS);
501         }
502     }
503
504     /**
505      * Called when the connection goes online. Schedule a general state update.
506      */
507     public void onConnectionOnline() {
508         cancelTask(updateOnlineStateTask, false);
509         updateOnlineStateTask = scheduler.schedule(() -> updateOnlineState(), 0, TimeUnit.MILLISECONDS);
510     }
511
512     /**
513      * Called when an SSE event message comes in with a valid list of resources. For each resource received, inform all
514      * child thing handlers with the respective resource.
515      *
516      * @param resources a list of incoming resource objects.
517      */
518     public void onResourcesEvent(List<Resource> resources) {
519         if (assetsLoaded) {
520             synchronized (resourcesEventTasks) {
521                 int index = resourcesEventTasks.size();
522                 resourcesEventTasks.put(index, scheduler.submit(() -> {
523                     onResourcesEventTask(resources);
524                     resourcesEventTasks.remove(index);
525                 }));
526             }
527         }
528     }
529
530     private void onResourcesEventTask(List<Resource> resources) {
531         logger.debug("onResourcesEventTask() resource count {}", resources.size());
532         getThing().getThings().forEach(thing -> {
533             ThingHandler handler = thing.getHandler();
534             if (handler instanceof Clip2ThingHandler) {
535                 resources.forEach(resource -> {
536                     ((Clip2ThingHandler) handler).onResource(resource);
537                 });
538             }
539         });
540     }
541
542     /**
543      * Execute an HTTP PUT to send a Resource object to the server.
544      *
545      * @param resource the resource to put.
546      * @return the resource, which may contain errors.
547      * @throws ApiException if a communication error occurred.
548      * @throws AssetNotLoadedException if one of the assets is not loaded.
549      * @throws InterruptedException
550      */
551     public Resources putResource(Resource resource) throws ApiException, AssetNotLoadedException, InterruptedException {
552         logger.debug("putResource() {}", resource);
553         checkAssetsLoaded();
554         return getClip2Bridge().putResource(resource);
555     }
556
557     /**
558      * Register the application key with the hub. If the current application key is empty it will create a new one.
559      *
560      * @throws HttpUnauthorizedException if the communication was OK but the registration failed anyway.
561      * @throws ApiException if a communication error occurred.
562      * @throws AssetNotLoadedException if one of the assets is not loaded.
563      * @throws IllegalStateException if the configuration cannot be changed e.g. read only.
564      * @throws InterruptedException
565      */
566     private void registerApplicationKey() throws HttpUnauthorizedException, ApiException, AssetNotLoadedException,
567             IllegalStateException, InterruptedException {
568         logger.debug("registerApplicationKey()");
569         Clip2BridgeConfig config = getConfigAs(Clip2BridgeConfig.class);
570         String newApplicationKey = getClip2Bridge().registerApplicationKey(config.applicationKey);
571         Configuration configuration = editConfiguration();
572         configuration.put(Clip2BridgeConfig.APPLICATION_KEY, newApplicationKey);
573         updateConfiguration(configuration);
574     }
575
576     /**
577      * Register the discovery service.
578      *
579      * @param discoveryService new discoveryService.
580      */
581     public void registerDiscoveryService(Clip2ThingDiscoveryService discoveryService) {
582         this.discoveryService = discoveryService;
583     }
584
585     /**
586      * Unregister the discovery service.
587      */
588     public void unregisterDiscoveryService() {
589         discoveryService = null;
590     }
591
592     /**
593      * Update the bridge's online state and update its dependent things. Called when the connection goes online.
594      */
595     private void updateOnlineState() {
596         if (assetsLoaded && (thing.getStatus() != ThingStatus.ONLINE)) {
597             logger.debug("updateOnlineState()");
598             connectRetriesRemaining = RECONNECT_MAX_TRIES;
599             updateStatus(ThingStatus.ONLINE);
600             updateThingsScheduled(500);
601             Clip2ThingDiscoveryService discoveryService = this.discoveryService;
602             if (Objects.nonNull(discoveryService)) {
603                 discoveryService.startScan(null);
604             }
605         }
606     }
607
608     /**
609      * Update the bridge thing properties.
610      *
611      * @throws ApiException if a communication error occurred.
612      * @throws AssetNotLoadedException if one of the assets is not loaded.
613      * @throws InterruptedException
614      */
615     private void updateProperties() throws ApiException, AssetNotLoadedException, InterruptedException {
616         logger.debug("updateProperties()");
617         Map<String, String> properties = new HashMap<>(thing.getProperties());
618
619         for (Resource device : getClip2Bridge().getResources(BRIDGE).getResources()) {
620             // set the serial number
621             String bridgeId = device.getBridgeId();
622             if (Objects.nonNull(bridgeId)) {
623                 properties.put(Thing.PROPERTY_SERIAL_NUMBER, bridgeId);
624             }
625             break;
626         }
627
628         for (Resource device : getClip2Bridge().getResources(DEVICE).getResources()) {
629             MetaData metaData = device.getMetaData();
630             if (Objects.nonNull(metaData) && metaData.getArchetype() == Archetype.BRIDGE_V2) {
631                 // set resource properties
632                 properties.put(PROPERTY_RESOURCE_ID, device.getId());
633                 properties.put(PROPERTY_RESOURCE_TYPE, device.getType().toString());
634
635                 // set metadata properties
636                 String metaDataName = metaData.getName();
637                 if (Objects.nonNull(metaDataName)) {
638                     properties.put(PROPERTY_RESOURCE_NAME, metaDataName);
639                 }
640                 properties.put(PROPERTY_RESOURCE_ARCHETYPE, metaData.getArchetype().toString());
641
642                 // set product data properties
643                 ProductData productData = device.getProductData();
644                 if (Objects.nonNull(productData)) {
645                     // set generic thing properties
646                     properties.put(Thing.PROPERTY_MODEL_ID, productData.getModelId());
647                     properties.put(Thing.PROPERTY_VENDOR, productData.getManufacturerName());
648                     properties.put(Thing.PROPERTY_FIRMWARE_VERSION, productData.getSoftwareVersion());
649                     String hardwarePlatformType = productData.getHardwarePlatformType();
650                     if (Objects.nonNull(hardwarePlatformType)) {
651                         properties.put(Thing.PROPERTY_HARDWARE_VERSION, hardwarePlatformType);
652                     }
653
654                     // set hue specific properties
655                     properties.put(PROPERTY_PRODUCT_NAME, productData.getProductName());
656                     properties.put(PROPERTY_PRODUCT_ARCHETYPE, productData.getProductArchetype().toString());
657                     properties.put(PROPERTY_PRODUCT_CERTIFIED, productData.getCertified().toString());
658                 }
659                 break; // we only needed the BRIDGE_V2 resource
660             }
661         }
662         thing.setProperties(properties);
663     }
664
665     /**
666      * Update the thing's own state. Called sporadically in case any SSE events may have been lost.
667      */
668     private void updateSelf() {
669         logger.debug("updateSelf()");
670         try {
671             checkAssetsLoaded();
672             updateProperties();
673             getClip2Bridge().open();
674         } catch (ApiException e) {
675             logger.trace("updateSelf() {}", e.getMessage(), e);
676             setStatusOfflineWithCommunicationError(e);
677             onConnectionOffline();
678         } catch (AssetNotLoadedException e) {
679             logger.trace("updateSelf() {}", e.getMessage(), e);
680             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
681                     "@text/offline.api2.conf-error.assets-not-loaded");
682         } catch (InterruptedException e) {
683         }
684     }
685
686     /**
687      * Check if a PROPERTY_LEGACY_THING_UID value was set by the discovery process, and if so, clone the legacy thing's
688      * settings into this thing.
689      */
690     private void updateThingFromLegacy() {
691         if (isInitialized()) {
692             logger.warn("Cannot update bridge thing '{}' from legacy since handler already initialized.",
693                     thing.getUID());
694             return;
695         }
696         Map<String, String> properties = thing.getProperties();
697         String legacyThingUID = properties.get(PROPERTY_LEGACY_THING_UID);
698         if (Objects.nonNull(legacyThingUID)) {
699             Thing legacyThing = thingRegistry.get(new ThingUID(legacyThingUID));
700             if (Objects.nonNull(legacyThing)) {
701                 BridgeBuilder editBuilder = editThing();
702
703                 String location = legacyThing.getLocation();
704                 if (Objects.nonNull(location) && !location.isBlank()) {
705                     editBuilder = editBuilder.withLocation(location);
706                 }
707
708                 Object userName = legacyThing.getConfiguration().get(USER_NAME);
709                 if (userName instanceof String) {
710                     Configuration configuration = thing.getConfiguration();
711                     configuration.put(Clip2BridgeConfig.APPLICATION_KEY, userName);
712                     editBuilder = editBuilder.withConfiguration(configuration);
713                 }
714
715                 Map<String, String> newProperties = new HashMap<>(properties);
716                 newProperties.remove(PROPERTY_LEGACY_THING_UID);
717
718                 updateThing(editBuilder.withProperties(newProperties).build());
719             }
720         }
721     }
722
723     /**
724      * Execute the mass download of all relevant resource types, and inform all child thing handlers.
725      */
726     private void updateThingsNow() {
727         logger.debug("updateThingsNow()");
728         try {
729             Clip2Bridge bridge = getClip2Bridge();
730             for (ResourceReference reference : MASS_DOWNLOAD_RESOURCE_REFERENCES) {
731                 ResourceType resourceType = reference.getType();
732                 List<Resource> resourceList = bridge.getResources(reference).getResources();
733                 switch (resourceType) {
734                     case ZONE:
735                         // add special 'All Lights' zone to the zone resource list
736                         resourceList.addAll(bridge.getResources(BRIDGE_HOME).getResources());
737                         break;
738
739                     case SCENE:
740                         // add 'smart scenes' to the scene resource list
741                         resourceList.addAll(bridge.getResources(SMART_SCENE).getResources());
742                         break;
743
744                     default:
745                         break;
746                 }
747                 getThing().getThings().forEach(thing -> {
748                     ThingHandler handler = thing.getHandler();
749                     if (handler instanceof Clip2ThingHandler) {
750                         ((Clip2ThingHandler) handler).onResourcesList(resourceType, resourceList);
751                     }
752                 });
753             }
754         } catch (ApiException | AssetNotLoadedException e) {
755             if (logger.isDebugEnabled()) {
756                 logger.debug("updateThingsNow() unexpected exception", e);
757             } else {
758                 logger.warn("Unexpected exception '{}' while updating things.", e.getMessage());
759             }
760         } catch (InterruptedException e) {
761         }
762     }
763
764     /**
765      * Schedule a task to call updateThings(). It prevents floods of GET calls when multiple child things are added at
766      * the same time.
767      *
768      * @param delayMilliSeconds the delay before running the next task.
769      */
770     private void updateThingsScheduled(int delayMilliSeconds) {
771         ScheduledFuture<?> task = this.scheduledUpdateTask;
772         if (Objects.isNull(task) || task.getDelay(TimeUnit.MILLISECONDS) < 100) {
773             cancelTask(scheduledUpdateTask, false);
774             scheduledUpdateTask = scheduler.schedule(() -> updateThingsNow(), delayMilliSeconds, TimeUnit.MILLISECONDS);
775         }
776     }
777 }