2 * Copyright (c) 2010-2024 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.hue.internal.handler;
15 import static org.openhab.binding.hue.internal.HueBindingConstants.*;
17 import java.io.IOException;
18 import java.util.Collection;
19 import java.util.HashMap;
20 import java.util.List;
22 import java.util.Objects;
23 import java.util.Optional;
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;
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;
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.
84 * @author Andrew Fiddian-Green - Initial contribution.
87 public class Clip2BridgeHandler extends BaseBridgeHandler {
89 public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Set.of(THING_TYPE_BRIDGE_API2);
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;
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);
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";
110 * List of resource references that need to be mass down loaded.
111 * NOTE: the SCENE resources must be mass down loaded first!
113 private static final List<ResourceReference> MASS_DOWNLOAD_RESOURCE_REFERENCES = List.of(SCENE, DEVICE, ROOM, ZONE);
115 private final Logger logger = LoggerFactory.getLogger(Clip2BridgeHandler.class);
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;
126 private @Nullable Clip2Bridge clip2Bridge;
127 private @Nullable ServiceRegistration<?> trustManagerRegistration;
128 private @Nullable Clip2ThingDiscoveryService discoveryService;
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<>();
136 private boolean assetsLoaded;
137 private int applKeyRetriesRemaining;
138 private int connectRetriesRemaining;
140 public Clip2BridgeHandler(Bridge bridge, HttpClientFactory httpClientFactory, ThingRegistry thingRegistry,
141 LocaleProvider localeProvider, TranslationProvider translationProvider) {
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);
152 * Cancel the given task.
154 * @param cancelTask the task to be cancelled (may be null)
155 * @param mayInterrupt allows cancel() to interrupt the thread.
157 private void cancelTask(@Nullable Future<?> cancelTask, boolean mayInterrupt) {
158 if (Objects.nonNull(cancelTask)) {
159 cancelTask.cancel(mayInterrupt);
164 * Check if assets are loaded.
166 * @throws AssetNotLoadedException if assets not loaded.
168 private void checkAssetsLoaded() throws AssetNotLoadedException {
170 throw new AssetNotLoadedException("Assets not loaded");
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.
180 private synchronized void checkConnection() {
181 logger.debug("checkConnection()");
183 boolean retryApplicationKey = false;
184 boolean retryConnection = false;
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");
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) {
212 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
213 "@text/offline.api2.conf-error.not-authorized");
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) {
228 if (retryApplicationKey) {
229 // short delay used during attempts to create or validate an application key
230 milliSeconds = FAST_SCHEDULE_MILLI_SECONDS;
231 applKeyRetriesRemaining--;
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--;
244 // this method schedules itself to be called again in a loop..
245 cancelTask(checkConnectionTask, false);
246 checkConnectionTask = scheduler.schedule(() -> checkConnection(), milliSeconds, TimeUnit.MILLISECONDS);
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() + "\"]");
256 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
257 "@text/offline.api2.comm-error.exception [\"" + e.getMessage() + " -> " + causeMessage + "\"]");
262 * If a child thing has been added, and the bridge is online, update the child's data.
264 public void childInitialized() {
265 if (thing.getStatus() == ThingStatus.ONLINE) {
266 updateThingsScheduled(5000);
271 public void dispose() {
278 * Dispose the bridge handler's assets. Called from dispose() on a thread, so that dispose() itself can complete
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();
297 ServiceRegistration<?> registration = trustManagerRegistration;
298 if (Objects.nonNull(registration)) {
299 registration.unregister();
300 trustManagerRegistration = null;
302 Clip2Bridge bridge = clip2Bridge;
303 if (Objects.nonNull(bridge)) {
307 Clip2ThingDiscoveryService disco = discoveryService;
308 if (Objects.nonNull(disco)) {
315 * Return the application key for the console app.
317 * @return the application key.
319 public String getApplicationKey() {
320 Clip2BridgeConfig config = getConfigAs(Clip2BridgeConfig.class);
321 return config.applicationKey;
325 * Get the Clip2Bridge connection and throw an exception if it is null.
327 * @return the Clip2Bridge.
328 * @throws AssetNotLoadedException if the Clip2Bridge is null.
330 private Clip2Bridge getClip2Bridge() throws AssetNotLoadedException {
331 Clip2Bridge clip2Bridge = this.clip2Bridge;
332 if (Objects.nonNull(clip2Bridge)) {
335 throw new AssetNotLoadedException("Clip2Bridge is null");
339 * Return the IP address for the console app.
341 * @return the IP address.
343 public String getIpAddress() {
344 Clip2BridgeConfig config = getConfigAs(Clip2BridgeConfig.class);
345 return config.ipAddress;
349 * Get the v1 legacy Hue bridge (if any) which has the same IP address as this.
351 * @return Optional result containing the legacy bridge (if any found).
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")))
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.
367 * @param targetIdV1 the idV1 attribute to match.
368 * @return Optional result containing the legacy thing (if found).
370 public Optional<Thing> getLegacyThing(String targetIdV1) {
371 Optional<Thing> legacyBridge = getLegacyBridge();
372 if (legacyBridge.isEmpty()) {
373 return Optional.empty();
377 if (targetIdV1.startsWith("/lights/")) {
379 } else if (targetIdV1.startsWith("/sensors/")) {
381 } else if (targetIdV1.startsWith("/groups/")) {
384 return Optional.empty();
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))
396 * Return a localized text.
398 * @param key the i18n text key.
399 * @param arguments for parameterized translation.
400 * @return the localized text.
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;
408 * Execute an HTTP GET for a resources reference object from the server.
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
417 public Resources getResources(ResourceReference reference)
418 throws ApiException, AssetNotLoadedException, InterruptedException {
419 logger.debug("getResources() {}", reference);
421 return getClip2Bridge().getResources(reference);
425 * Getter for the scheduler.
427 * @return the scheduler.
429 public ScheduledExecutorService getScheduler() {
434 public Collection<Class<? extends ThingHandlerService>> getServices() {
435 return Set.of(Clip2ThingDiscoveryService.class);
439 public void handleCommand(ChannelUID channelUID, Command command) {
440 if (CHANNEL_GROUP_AUTOMATION.equals(channelUID.getGroupId())) {
442 if (RefreshType.REFRESH.equals(command)) {
443 updateAutomationChannelsNow();
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()));
453 } catch (ApiException | AssetNotLoadedException e) {
454 logger.warn("handleCommand({}, {}) error {}", channelUID, command, e.getMessage(),
455 logger.isDebugEnabled() ? e : null);
456 } catch (InterruptedException e) {
462 public void initialize() {
463 updateThingFromLegacy();
464 updateStatus(ThingStatus.UNKNOWN);
465 applKeyRetriesRemaining = APPLICATION_KEY_MAX_TRIES;
466 connectRetriesRemaining = RECONNECT_MAX_TRIES;
471 * Initialize the bridge handler's assets.
473 private void initializeAssets() {
474 logger.debug("initializeAssets() {}", this);
475 synchronized (this) {
476 Clip2BridgeConfig config = getConfigAs(Clip2BridgeConfig.class);
478 String ipAddress = config.ipAddress;
479 if (ipAddress.isBlank()) {
480 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
481 "@text/offline.conf-error-no-ip-address");
486 if (!Clip2Bridge.isClip2Supported(ipAddress)) {
487 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
488 "@text/offline.api2.conf-error.clip2-not-supported");
491 } catch (IOException e) {
492 logger.trace("initializeAssets() communication error on '{}'", ipAddress, e);
493 setStatusOfflineWithCommunicationError(e);
497 HueTlsTrustManagerProvider trustManagerProvider = new HueTlsTrustManagerProvider(ipAddress + ":443",
498 config.useSelfSignedCertificate);
500 if (Objects.isNull(trustManagerProvider.getPEMTrustManager())) {
501 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
502 "@text/offline.api2.conf-error.certificate-load");
506 trustManagerRegistration = FrameworkUtil.getBundle(getClass()).getBundleContext()
507 .registerService(TlsTrustManagerProvider.class.getName(), trustManagerProvider, null);
509 String applicationKey = config.applicationKey;
510 applicationKey = Objects.nonNull(applicationKey) ? applicationKey : "";
513 clip2Bridge = new Clip2Bridge(httpClientFactory, this, ipAddress, applicationKey);
514 } catch (ApiException e) {
515 logger.trace("initializeAssets() communication error on '{}'", ipAddress, e);
516 setStatusOfflineWithCommunicationError(e);
522 cancelTask(checkConnectionTask, false);
523 checkConnectionTask = scheduler.submit(() -> checkConnection());
527 * Called when the connection goes offline. Schedule a reconnection.
529 public void onConnectionOffline() {
531 cancelTask(checkConnectionTask, false);
532 checkConnectionTask = scheduler.schedule(() -> checkConnection(), RECONNECT_DELAY_SECONDS,
538 * Called when the connection goes online. Schedule a general state update.
540 public void onConnectionOnline() {
541 cancelTask(updateOnlineStateTask, false);
542 updateOnlineStateTask = scheduler.schedule(() -> updateOnlineState(), 0, TimeUnit.MILLISECONDS);
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.
549 * @param resources a list of incoming resource objects.
551 public void onResourcesEvent(List<Resource> resources) {
553 synchronized (resourcesEventTasks) {
554 int index = resourcesEventTasks.size();
555 resourcesEventTasks.put(index, scheduler.submit(() -> {
556 onResourcesEventTask(resources);
557 resourcesEventTasks.remove(index);
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());
570 if (onResources(resources)) {
571 updateAutomationChannelsNow();
573 getThing().getThings().forEach(thing -> {
574 if (thing.getHandler() instanceof Clip2ThingHandler clip2ThingHandler) {
575 clip2ThingHandler.onResources(resources);
581 * Execute an HTTP PUT to send a Resource object to the server.
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
589 public Resources putResource(Resource resource) throws ApiException, AssetNotLoadedException, InterruptedException {
590 logger.debug("putResource() {}", resource);
592 return getClip2Bridge().putResource(resource);
596 * Register the application key with the hub. If the current application key is empty it will create a new one.
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
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);
615 * Register the discovery service.
617 * @param discoveryService new discoveryService.
619 public void registerDiscoveryService(Clip2ThingDiscoveryService discoveryService) {
620 this.discoveryService = discoveryService;
624 * Unregister the discovery service.
626 public void unregisterDiscoveryService() {
627 discoveryService = null;
631 * Update the bridge's online state and update its dependent things. Called when the connection goes online.
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);
649 * Update the bridge thing properties.
651 * @throws ApiException if a communication error occurred.
652 * @throws AssetNotLoadedException if one of the assets is not loaded.
653 * @throws InterruptedException
655 private void updateProperties() throws ApiException, AssetNotLoadedException, InterruptedException {
656 logger.debug("updateProperties()");
657 Map<String, String> properties = new HashMap<>(thing.getProperties());
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);
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());
675 // set metadata properties
676 String metaDataName = metaData.getName();
677 if (Objects.nonNull(metaDataName)) {
678 properties.put(PROPERTY_RESOURCE_NAME, metaDataName);
680 properties.put(PROPERTY_RESOURCE_ARCHETYPE, metaData.getArchetype().toString());
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);
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());
699 break; // we only needed the BRIDGE_V2 resource
702 thing.setProperties(properties);
706 * Update the thing's own state. Called sporadically in case any SSE events may have been lost.
708 private void updateSelf() {
709 logger.debug("updateSelf()");
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) {
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.
730 private void updateThingFromLegacy() {
731 if (isInitialized()) {
732 logger.warn("Cannot update bridge thing '{}' from legacy since handler already initialized.",
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();
743 String location = legacyThing.getLocation();
744 if (Objects.nonNull(location) && !location.isBlank()) {
745 editBuilder = editBuilder.withLocation(location);
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);
755 Map<String, String> newProperties = new HashMap<>(properties);
756 newProperties.remove(PROPERTY_LEGACY_THING_UID);
758 updateThing(editBuilder.withProperties(newProperties).build());
764 * Execute the mass download of all relevant resource types, and inform all child thing handlers.
766 private void updateThingsNow() {
767 logger.debug("updateThingsNow()");
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) {
775 // add special 'All Lights' zone to the zone resource list
776 resourceList.addAll(bridge.getResources(BRIDGE_HOME).getResources());
780 // add 'smart scenes' to the scene resource list
781 resourceList.addAll(bridge.getResources(SMART_SCENE).getResources());
787 getThing().getThings().forEach(thing -> {
788 ThingHandler handler = thing.getHandler();
789 if (handler instanceof Clip2ThingHandler) {
790 ((Clip2ThingHandler) handler).onResourcesList(resourceType, resourceList);
794 } catch (ApiException | AssetNotLoadedException e) {
795 if (logger.isDebugEnabled()) {
796 logger.debug("updateThingsNow() unexpected exception", e);
798 logger.warn("Unexpected exception '{}' while updating things.", e.getMessage());
800 } catch (InterruptedException e) {
805 * Schedule a task to call updateThings(). It prevents floods of GET calls when multiple child things are added at
808 * @param delayMilliSeconds the delay before running the next task.
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);
819 * Load the set of automation script ids.
821 private void loadAutomationScriptIds() {
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()));
829 } catch (ApiException | AssetNotLoadedException e) {
830 logger.warn("loadAutomationScriptIds() unexpected exception {}", e.getMessage(),
831 logger.isDebugEnabled() ? e : null);
832 } catch (InterruptedException e) {
837 * Create resp. update the automation channels
839 private void updateAutomationChannels() {
840 List<Resource> automations;
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);
848 } catch (InterruptedException e) {
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());
857 synchronized (automationsCache) {
858 automationsCache.clear();
859 automationsCache.putAll(automations.stream().collect(Collectors.toMap(a -> a.getId(), a -> a)));
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()));
866 updateThing(editThing().withChannels(Stream.concat(oldchannels, newChannels).toList()).build());
867 onResources(automations);
869 logger.debug("Bridge created {} automation channels", automations.size());
874 * Start a task to update the automation channels
876 private void updateAutomationChannelsNow() {
877 cancelTask(updateAutomationChannelsTask, false);
878 updateAutomationChannelsTask = scheduler.submit(() -> updateAutomationChannels());
882 * Create an automation channel from an automation resource
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);
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);
894 return ChannelBuilder
895 .create(new ChannelUID(automationChannelGroupUID, automation.getId()), CoreItemFactory.SWITCH)
896 .withLabel(label).withDescription(description).withType(CHANNEL_TYPE_AUTOMATION).build();
900 * Process event resources list
902 * @return true if the automation channels require updating
904 public boolean onResources(List<Resource> resources) {
905 boolean requireUpdateChannels = false;
906 for (Resource resource : resources) {
907 if (ResourceType.BEHAVIOR_INSTANCE != resource.getType()) {
910 String resourceId = resource.getId();
911 switch (resource.getContentType()) {
913 requireUpdateChannels |= automationScriptIds.contains(resource.getScriptId());
916 requireUpdateChannels |= automationsCache.containsKey(resourceId);
920 Resource cachedAutomation = automationsCache.get(resourceId);
921 if (Objects.isNull(cachedAutomation)) {
922 requireUpdateChannels |= automationScriptIds.contains(resource.getScriptId());
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());
935 return requireUpdateChannels;