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