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