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