]> git.basschouten.com Git - openhab-addons.git/blob
84586791b069d4a7c236da775505778a8560677c
[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.math.BigDecimal;
18 import java.time.Duration;
19 import java.time.Instant;
20 import java.util.Collection;
21 import java.util.HashMap;
22 import java.util.HashSet;
23 import java.util.List;
24 import java.util.Map;
25 import java.util.Objects;
26 import java.util.Set;
27 import java.util.concurrent.ConcurrentHashMap;
28 import java.util.concurrent.CopyOnWriteArrayList;
29 import java.util.concurrent.Future;
30 import java.util.concurrent.TimeUnit;
31 import java.util.stream.Collectors;
32
33 import org.eclipse.jdt.annotation.NonNullByDefault;
34 import org.eclipse.jdt.annotation.Nullable;
35 import org.openhab.binding.hue.internal.action.DynamicsActions;
36 import org.openhab.binding.hue.internal.config.Clip2ThingConfig;
37 import org.openhab.binding.hue.internal.dto.clip2.Alerts;
38 import org.openhab.binding.hue.internal.dto.clip2.ColorXy;
39 import org.openhab.binding.hue.internal.dto.clip2.Dimming;
40 import org.openhab.binding.hue.internal.dto.clip2.Effects;
41 import org.openhab.binding.hue.internal.dto.clip2.Gamut2;
42 import org.openhab.binding.hue.internal.dto.clip2.MetaData;
43 import org.openhab.binding.hue.internal.dto.clip2.MirekSchema;
44 import org.openhab.binding.hue.internal.dto.clip2.ProductData;
45 import org.openhab.binding.hue.internal.dto.clip2.Resource;
46 import org.openhab.binding.hue.internal.dto.clip2.ResourceReference;
47 import org.openhab.binding.hue.internal.dto.clip2.enums.ActionType;
48 import org.openhab.binding.hue.internal.dto.clip2.enums.EffectType;
49 import org.openhab.binding.hue.internal.dto.clip2.enums.RecallAction;
50 import org.openhab.binding.hue.internal.dto.clip2.enums.ResourceType;
51 import org.openhab.binding.hue.internal.dto.clip2.enums.ZigbeeStatus;
52 import org.openhab.binding.hue.internal.dto.clip2.helper.Setters;
53 import org.openhab.binding.hue.internal.exceptions.ApiException;
54 import org.openhab.binding.hue.internal.exceptions.AssetNotLoadedException;
55 import org.openhab.core.library.types.DateTimeType;
56 import org.openhab.core.library.types.DecimalType;
57 import org.openhab.core.library.types.HSBType;
58 import org.openhab.core.library.types.IncreaseDecreaseType;
59 import org.openhab.core.library.types.OnOffType;
60 import org.openhab.core.library.types.PercentType;
61 import org.openhab.core.library.types.QuantityType;
62 import org.openhab.core.library.types.StringType;
63 import org.openhab.core.library.unit.MetricPrefix;
64 import org.openhab.core.library.unit.Units;
65 import org.openhab.core.thing.Bridge;
66 import org.openhab.core.thing.Channel;
67 import org.openhab.core.thing.ChannelUID;
68 import org.openhab.core.thing.Thing;
69 import org.openhab.core.thing.ThingRegistry;
70 import org.openhab.core.thing.ThingStatus;
71 import org.openhab.core.thing.ThingStatusDetail;
72 import org.openhab.core.thing.ThingTypeUID;
73 import org.openhab.core.thing.ThingUID;
74 import org.openhab.core.thing.binding.BaseThingHandler;
75 import org.openhab.core.thing.binding.BridgeHandler;
76 import org.openhab.core.thing.binding.ThingHandlerService;
77 import org.openhab.core.thing.binding.builder.ThingBuilder;
78 import org.openhab.core.thing.link.ItemChannelLink;
79 import org.openhab.core.thing.link.ItemChannelLinkRegistry;
80 import org.openhab.core.types.Command;
81 import org.openhab.core.types.RefreshType;
82 import org.openhab.core.types.State;
83 import org.openhab.core.types.StateOption;
84 import org.openhab.core.types.UnDefType;
85 import org.slf4j.Logger;
86 import org.slf4j.LoggerFactory;
87
88 /**
89  * Handler for things based on CLIP 2 'device', 'room', or 'zone resources.
90  *
91  * @author Andrew Fiddian-Green - Initial contribution.
92  */
93 @NonNullByDefault
94 public class Clip2ThingHandler extends BaseThingHandler {
95
96     public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Set.of(THING_TYPE_DEVICE, THING_TYPE_ROOM,
97             THING_TYPE_ZONE);
98
99     private static final Duration DYNAMICS_ACTIVE_WINDOW = Duration.ofSeconds(10);
100
101     private static final String LK_WISER_DIMMER_MODEL_ID = "LK Dimmer";
102
103     private final Logger logger = LoggerFactory.getLogger(Clip2ThingHandler.class);
104
105     /**
106      * A map of service Resources whose state contributes to the overall state of this thing. It is a map between the
107      * resource ID (string) and a Resource object containing the last known state. e.g. a DEVICE thing may support a
108      * LIGHT service whose Resource contributes to its overall state, or a ROOM or ZONE thing may support a
109      * GROUPED_LIGHT service whose Resource contributes to the its overall state.
110      */
111     private final Map<String, Resource> serviceContributorsCache = new ConcurrentHashMap<>();
112
113     /**
114      * A map of Resource IDs which are targets for commands to be sent. It is a map between the type of command
115      * (ResourcesType) and the resource ID to which the command shall be sent. e.g. a LIGHT 'on' command shall be sent
116      * to the respective LIGHT resource ID.
117      */
118     private final Map<ResourceType, String> commandResourceIds = new ConcurrentHashMap<>();
119
120     /**
121      * Button devices contain one or more physical buttons, each of which is represented by a BUTTON Resource with its
122      * own unique resource ID, and a respective controlId that indicates which button it is in the device. e.g. a dimmer
123      * pad has four buttons (controlId's 1..4) each represented by a BUTTON Resource with a unique resource ID. This is
124      * a map between the resource ID and its respective controlId.
125      */
126     private final Map<String, Integer> controlIds = new ConcurrentHashMap<>();
127
128     /**
129      * The set of channel IDs that are supported by this thing. e.g. an on/off light may support 'switch' and
130      * 'zigbeeStatus' channels, whereas a complex light may support 'switch', 'brightness', 'color', 'color temperature'
131      * and 'zigbeeStatus' channels.
132      */
133     private final Set<String> supportedChannelIdSet = new HashSet<>();
134
135     /**
136      * A map of scene IDs and respective scene Resources for the scenes that contribute to and command this thing. It is
137      * a map between the resource ID (string) and a Resource object containing the scene's last known state.
138      */
139     private final Map<String, Resource> sceneContributorsCache = new ConcurrentHashMap<>();
140
141     /**
142      * A map of scene names versus Resource IDs for the scenes that contribute to and command this thing. e.g. a command
143      * for a scene named 'Energize' shall be sent to the respective SCENE resource ID.
144      */
145     private final Map<String, String> sceneResourceIds = new ConcurrentHashMap<>();
146
147     /**
148      * A list of API v1 thing channel UIDs that are linked to items. It is used in the process of replicating the
149      * Item/Channel links from a legacy v1 thing to this API v2 thing.
150      */
151     private final List<ChannelUID> legacyLinkedChannelUIDs = new CopyOnWriteArrayList<>();
152
153     private final ThingRegistry thingRegistry;
154     private final ItemChannelLinkRegistry itemChannelLinkRegistry;
155     private final Clip2StateDescriptionProvider stateDescriptionProvider;
156
157     private String resourceId = "?";
158     private Resource thisResource;
159     private Duration dynamicsDuration = Duration.ZERO;
160     private Instant dynamicsExpireTime = Instant.MIN;
161
162     private boolean disposing;
163     private boolean hasConnectivityIssue;
164     private boolean updateSceneContributorsDone;
165     private boolean updateLightPropertiesDone;
166     private boolean updatePropertiesDone;
167     private boolean updateDependenciesDone;
168     private boolean applyOffTransitionWorkaround;
169
170     private @Nullable Future<?> alertResetTask;
171     private @Nullable Future<?> dynamicsResetTask;
172     private @Nullable Future<?> updateDependenciesTask;
173     private @Nullable Future<?> updateServiceContributorsTask;
174
175     public Clip2ThingHandler(Thing thing, Clip2StateDescriptionProvider stateDescriptionProvider,
176             ThingRegistry thingRegistry, ItemChannelLinkRegistry itemChannelLinkRegistry) {
177         super(thing);
178
179         ThingTypeUID thingTypeUID = thing.getThingTypeUID();
180         if (THING_TYPE_DEVICE.equals(thingTypeUID)) {
181             thisResource = new Resource(ResourceType.DEVICE);
182         } else if (THING_TYPE_ROOM.equals(thingTypeUID)) {
183             thisResource = new Resource(ResourceType.ROOM);
184         } else if (THING_TYPE_ZONE.equals(thingTypeUID)) {
185             thisResource = new Resource(ResourceType.ZONE);
186         } else {
187             throw new IllegalArgumentException("Wrong thing type " + thingTypeUID.getAsString());
188         }
189
190         this.thingRegistry = thingRegistry;
191         this.itemChannelLinkRegistry = itemChannelLinkRegistry;
192         this.stateDescriptionProvider = stateDescriptionProvider;
193     }
194
195     /**
196      * Add a channel ID to the supportedChannelIdSet set. If the channel supports dynamics (timed transitions) then add
197      * the respective channel as well.
198      *
199      * @param channelId the channel ID to add.
200      */
201     private void addSupportedChannel(String channelId) {
202         if (!disposing && !updateDependenciesDone) {
203             synchronized (supportedChannelIdSet) {
204                 logger.debug("{} -> addSupportedChannel() '{}' added to supported channel set", resourceId, channelId);
205                 supportedChannelIdSet.add(channelId);
206                 if (DYNAMIC_CHANNELS.contains(channelId)) {
207                     clearDynamicsChannel();
208                 }
209             }
210         }
211     }
212
213     /**
214      * Cancel the given task.
215      *
216      * @param cancelTask the task to be cancelled (may be null)
217      * @param mayInterrupt allows cancel() to interrupt the thread.
218      */
219     private void cancelTask(@Nullable Future<?> cancelTask, boolean mayInterrupt) {
220         if (Objects.nonNull(cancelTask)) {
221             cancelTask.cancel(mayInterrupt);
222         }
223     }
224
225     /**
226      * Clear the dynamics channel parameters.
227      */
228     private void clearDynamicsChannel() {
229         dynamicsExpireTime = Instant.MIN;
230         dynamicsDuration = Duration.ZERO;
231         updateState(CHANNEL_2_DYNAMICS, new QuantityType<>(0, MetricPrefix.MILLI(Units.SECOND)), true);
232     }
233
234     @Override
235     public void dispose() {
236         logger.debug("{} -> dispose()", resourceId);
237         disposing = true;
238         cancelTask(alertResetTask, true);
239         cancelTask(dynamicsResetTask, true);
240         cancelTask(updateDependenciesTask, true);
241         cancelTask(updateServiceContributorsTask, true);
242         alertResetTask = null;
243         dynamicsResetTask = null;
244         updateDependenciesTask = null;
245         updateServiceContributorsTask = null;
246         legacyLinkedChannelUIDs.clear();
247         sceneContributorsCache.clear();
248         sceneResourceIds.clear();
249         supportedChannelIdSet.clear();
250         commandResourceIds.clear();
251         serviceContributorsCache.clear();
252         controlIds.clear();
253     }
254
255     /**
256      * Get the bridge handler.
257      *
258      * @throws AssetNotLoadedException if the handler does not exist.
259      */
260     private Clip2BridgeHandler getBridgeHandler() throws AssetNotLoadedException {
261         Bridge bridge = getBridge();
262         if (Objects.nonNull(bridge)) {
263             BridgeHandler handler = bridge.getHandler();
264             if (handler instanceof Clip2BridgeHandler) {
265                 return (Clip2BridgeHandler) handler;
266             }
267         }
268         throw new AssetNotLoadedException("Bridge handler missing");
269     }
270
271     /**
272      * Do a double lookup to get the cached resource that matches the given ResourceType.
273      *
274      * @param resourceType the type to search for.
275      * @return the Resource, or null if not found.
276      */
277     private @Nullable Resource getCachedResource(ResourceType resourceType) {
278         String commandResourceId = commandResourceIds.get(resourceType);
279         return Objects.nonNull(commandResourceId) ? serviceContributorsCache.get(commandResourceId) : null;
280     }
281
282     /**
283      * Return a ResourceReference to this handler's resource.
284      *
285      * @return a ResourceReference instance.
286      */
287     public ResourceReference getResourceReference() {
288         return new ResourceReference().setId(resourceId).setType(thisResource.getType());
289     }
290
291     /**
292      * Register the 'DynamicsAction' service.
293      */
294     @Override
295     public Collection<Class<? extends ThingHandlerService>> getServices() {
296         return Set.of(DynamicsActions.class);
297     }
298
299     @Override
300     public void handleCommand(ChannelUID channelUID, Command commandParam) {
301         if (RefreshType.REFRESH.equals(commandParam)) {
302             if ((thing.getStatus() == ThingStatus.ONLINE) && updateDependenciesDone) {
303                 Future<?> task = updateServiceContributorsTask;
304                 if (Objects.isNull(task) || !task.isDone()) {
305                     cancelTask(updateServiceContributorsTask, false);
306                     updateServiceContributorsTask = scheduler.schedule(() -> {
307                         try {
308                             updateServiceContributors();
309                         } catch (ApiException | AssetNotLoadedException e) {
310                             logger.debug("{} -> handleCommand() error {}", resourceId, e.getMessage(), e);
311                         } catch (InterruptedException e) {
312                         }
313                     }, 3, TimeUnit.SECONDS);
314                 }
315             }
316             return;
317         }
318
319         Channel channel = thing.getChannel(channelUID);
320         if (channel == null) {
321             if (logger.isDebugEnabled()) {
322                 logger.debug("{} -> handleCommand() channelUID:{} does not exist", resourceId, channelUID);
323
324             } else {
325                 logger.warn("Command received for channel '{}' which is not in thing '{}'.", channelUID,
326                         thing.getUID());
327             }
328             return;
329         }
330
331         ResourceType lightResourceType = thisResource.getType() == ResourceType.DEVICE ? ResourceType.LIGHT
332                 : ResourceType.GROUPED_LIGHT;
333
334         Resource putResource = null;
335         String putResourceId = null;
336         Command command = commandParam;
337         String channelId = channelUID.getId();
338         Resource cache = getCachedResource(lightResourceType);
339
340         switch (channelId) {
341             case CHANNEL_2_ALERT:
342                 putResource = Setters.setAlert(new Resource(lightResourceType), command, cache);
343                 cancelTask(alertResetTask, false);
344                 alertResetTask = scheduler.schedule(
345                         () -> updateState(channelUID, new StringType(ActionType.NO_ACTION.name())), 10,
346                         TimeUnit.SECONDS);
347                 break;
348
349             case CHANNEL_2_EFFECT:
350                 putResource = Setters.setEffect(new Resource(lightResourceType), command, cache);
351                 putResource.setOnOff(OnOffType.ON);
352                 break;
353
354             case CHANNEL_2_COLOR_TEMP_PERCENT:
355                 if (command instanceof IncreaseDecreaseType) {
356                     if (Objects.nonNull(cache)) {
357                         State current = cache.getColorTemperaturePercentState();
358                         if (current instanceof PercentType) {
359                             int sign = IncreaseDecreaseType.INCREASE == command ? 1 : -1;
360                             int percent = ((PercentType) current).intValue() + (sign * (int) Resource.PERCENT_DELTA);
361                             command = new PercentType(Math.min(100, Math.max(0, percent)));
362                         }
363                     }
364                 } else if (command instanceof OnOffType) {
365                     command = OnOffType.OFF == command ? PercentType.ZERO : PercentType.HUNDRED;
366                 }
367                 putResource = Setters.setColorTemperaturePercent(new Resource(lightResourceType), command, cache);
368                 break;
369
370             case CHANNEL_2_COLOR_TEMP_ABSOLUTE:
371                 putResource = Setters.setColorTemperatureAbsolute(new Resource(lightResourceType), command, cache);
372                 break;
373
374             case CHANNEL_2_COLOR:
375                 putResource = new Resource(lightResourceType);
376                 if (command instanceof HSBType) {
377                     HSBType color = ((HSBType) command);
378                     putResource = Setters.setColorXy(putResource, color, cache);
379                     command = color.getBrightness();
380                 }
381                 // NB fall through for handling of brightness and switch related commands !!
382
383             case CHANNEL_2_BRIGHTNESS:
384                 putResource = Objects.nonNull(putResource) ? putResource : new Resource(lightResourceType);
385                 if (command instanceof IncreaseDecreaseType) {
386                     if (Objects.nonNull(cache)) {
387                         State current = cache.getBrightnessState();
388                         if (current instanceof PercentType) {
389                             int sign = IncreaseDecreaseType.INCREASE == command ? 1 : -1;
390                             double percent = ((PercentType) current).doubleValue() + (sign * Resource.PERCENT_DELTA);
391                             command = new PercentType(new BigDecimal(Math.min(100f, Math.max(0f, percent)),
392                                     Resource.PERCENT_MATH_CONTEXT));
393                         }
394                     }
395                 }
396                 if (command instanceof PercentType) {
397                     PercentType brightness = (PercentType) command;
398                     putResource = Setters.setDimming(putResource, brightness, cache);
399                     Double minDimLevel = Objects.nonNull(cache) ? cache.getMinimumDimmingLevel() : null;
400                     minDimLevel = Objects.nonNull(minDimLevel) ? minDimLevel : Dimming.DEFAULT_MINIMUM_DIMMIMG_LEVEL;
401                     command = OnOffType.from(brightness.doubleValue() >= minDimLevel);
402                 }
403                 // NB fall through for handling of switch related commands !!
404
405             case CHANNEL_2_SWITCH:
406                 putResource = Objects.nonNull(putResource) ? putResource : new Resource(lightResourceType);
407                 putResource.setOnOff(command);
408                 applyDeviceSpecificWorkArounds(command, putResource);
409                 break;
410
411             case CHANNEL_2_COLOR_XY_ONLY:
412                 putResource = Setters.setColorXy(new Resource(lightResourceType), command, cache);
413                 break;
414
415             case CHANNEL_2_DIMMING_ONLY:
416                 putResource = Setters.setDimming(new Resource(lightResourceType), command, cache);
417                 break;
418
419             case CHANNEL_2_ON_OFF_ONLY:
420                 putResource = new Resource(lightResourceType).setOnOff(command);
421                 applyDeviceSpecificWorkArounds(command, putResource);
422                 break;
423
424             case CHANNEL_2_TEMPERATURE_ENABLED:
425                 putResource = new Resource(ResourceType.TEMPERATURE).setEnabled(command);
426                 break;
427
428             case CHANNEL_2_MOTION_ENABLED:
429                 putResource = new Resource(ResourceType.MOTION).setEnabled(command);
430                 break;
431
432             case CHANNEL_2_LIGHT_LEVEL_ENABLED:
433                 putResource = new Resource(ResourceType.LIGHT_LEVEL).setEnabled(command);
434                 break;
435
436             case CHANNEL_2_SCENE:
437                 if (command instanceof StringType) {
438                     putResourceId = sceneResourceIds.get(((StringType) command).toString());
439                     if (Objects.nonNull(putResourceId)) {
440                         putResource = new Resource(ResourceType.SCENE).setRecallAction(RecallAction.ACTIVE);
441                     }
442                 }
443                 break;
444
445             case CHANNEL_2_DYNAMICS:
446                 Duration clearAfter = Duration.ZERO;
447                 if (command instanceof QuantityType<?>) {
448                     QuantityType<?> durationMs = ((QuantityType<?>) command).toUnit(MetricPrefix.MILLI(Units.SECOND));
449                     if (Objects.nonNull(durationMs) && durationMs.longValue() > 0) {
450                         Duration duration = Duration.ofMillis(durationMs.longValue());
451                         dynamicsDuration = duration;
452                         dynamicsExpireTime = Instant.now().plus(DYNAMICS_ACTIVE_WINDOW);
453                         clearAfter = DYNAMICS_ACTIVE_WINDOW;
454                         logger.debug("{} -> handleCommand() dynamics setting {} valid for {}", resourceId, duration,
455                                 clearAfter);
456                     }
457                 }
458                 cancelTask(dynamicsResetTask, false);
459                 dynamicsResetTask = scheduler.schedule(() -> clearDynamicsChannel(), clearAfter.toMillis(),
460                         TimeUnit.MILLISECONDS);
461                 return;
462
463             default:
464                 if (logger.isDebugEnabled()) {
465                     logger.debug("{} -> handleCommand() channelUID:{} unknown", resourceId, channelUID);
466                 } else {
467                     logger.warn("Command received for unknown channel '{}'.", channelUID);
468                 }
469                 return;
470         }
471
472         if (putResource == null) {
473             if (logger.isDebugEnabled()) {
474                 logger.debug("{} -> handleCommand() command:{} not supported on channelUID:{}", resourceId, command,
475                         channelUID);
476             } else {
477                 logger.warn("Command '{}' is not supported on channel '{}'.", command, channelUID);
478             }
479             return;
480         }
481
482         putResourceId = Objects.nonNull(putResourceId) ? putResourceId : commandResourceIds.get(putResource.getType());
483         if (putResourceId == null) {
484             if (logger.isDebugEnabled()) {
485                 logger.debug(
486                         "{} -> handleCommand() channelUID:{}, command:{}, putResourceType:{} => missing resource ID",
487                         resourceId, channelUID, command, putResource.getType());
488             } else {
489                 logger.warn("Command '{}' for channel '{}' cannot be processed by thing '{}'.", command, channelUID,
490                         thing.getUID());
491             }
492             return;
493         }
494
495         if (DYNAMIC_CHANNELS.contains(channelId)) {
496             if (Instant.now().isBefore(dynamicsExpireTime) && !dynamicsDuration.isZero()
497                     && !dynamicsDuration.isNegative()) {
498                 if (ResourceType.SCENE == putResource.getType()) {
499                     putResource.setRecallDuration(dynamicsDuration);
500                 } else {
501                     putResource.setDynamicsDuration(dynamicsDuration);
502                 }
503             }
504         }
505
506         putResource.setId(putResourceId);
507         logger.debug("{} -> handleCommand() put resource {}", resourceId, putResource);
508
509         try {
510             getBridgeHandler().putResource(putResource);
511         } catch (ApiException | AssetNotLoadedException e) {
512             if (logger.isDebugEnabled()) {
513                 logger.debug("{} -> handleCommand() error {}", resourceId, e.getMessage(), e);
514             } else {
515                 logger.warn("Command '{}' for thing '{}', channel '{}' failed with error '{}'.", command,
516                         thing.getUID(), channelUID, e.getMessage());
517             }
518         } catch (InterruptedException e) {
519         }
520     }
521
522     /**
523      * Apply device specific work-arounds needed for given command.
524      *
525      * @param command the handled command.
526      * @param putResource the resource that will be adjusted if needed.
527      */
528     private void applyDeviceSpecificWorkArounds(Command command, Resource putResource) {
529         if (command == OnOffType.OFF && applyOffTransitionWorkaround) {
530             putResource.setDynamicsDuration(dynamicsDuration);
531         }
532     }
533
534     /**
535      * Handle a 'dynamics' command for the given channel ID for the given dynamics duration.
536      *
537      * @param channelId the ID of the target channel.
538      * @param command the new target state.
539      * @param duration the transition duration.
540      */
541     public synchronized void handleDynamicsCommand(String channelId, Command command, QuantityType<?> duration) {
542         if (DYNAMIC_CHANNELS.contains(channelId)) {
543             Channel dynamicsChannel = thing.getChannel(CHANNEL_2_DYNAMICS);
544             Channel targetChannel = thing.getChannel(channelId);
545             if (Objects.nonNull(dynamicsChannel) && Objects.nonNull(targetChannel)) {
546                 logger.debug("{} - handleDynamicsCommand() channelId:{}, command:{}, duration:{}", resourceId,
547                         channelId, command, duration);
548                 handleCommand(dynamicsChannel.getUID(), duration);
549                 handleCommand(targetChannel.getUID(), command);
550                 return;
551             }
552         }
553         logger.warn("Dynamics command '{}' for thing '{}', channel '{}' and duration'{}' failed.", command,
554                 thing.getUID(), channelId, duration);
555     }
556
557     @Override
558     public void initialize() {
559         Clip2ThingConfig config = getConfigAs(Clip2ThingConfig.class);
560
561         String resourceId = config.resourceId;
562         if (resourceId.isBlank()) {
563             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
564                     "@text/offline.api2.conf-error.resource-id-bad");
565             return;
566         }
567         thisResource.setId(resourceId);
568         this.resourceId = resourceId;
569         logger.debug("{} -> initialize()", resourceId);
570
571         updateThingFromLegacy();
572         updateStatus(ThingStatus.UNKNOWN);
573
574         dynamicsDuration = Duration.ZERO;
575         dynamicsExpireTime = Instant.MIN;
576
577         disposing = false;
578         hasConnectivityIssue = false;
579         updatePropertiesDone = false;
580         updateDependenciesDone = false;
581         updateLightPropertiesDone = false;
582         updateSceneContributorsDone = false;
583
584         Bridge bridge = getBridge();
585         if (Objects.nonNull(bridge)) {
586             BridgeHandler bridgeHandler = bridge.getHandler();
587             if (bridgeHandler instanceof Clip2BridgeHandler) {
588                 ((Clip2BridgeHandler) bridgeHandler).childInitialized();
589             }
590         }
591     }
592
593     /**
594      * Update the channel state depending on a new resource sent from the bridge.
595      *
596      * @param resource a Resource object containing the new state.
597      */
598     public void onResource(Resource resource) {
599         if (!disposing) {
600             boolean resourceConsumed = false;
601             String incomingResourceId = resource.getId();
602             if (resourceId.equals(incomingResourceId)) {
603                 if (resource.hasFullState()) {
604                     thisResource = resource;
605                     if (!updatePropertiesDone) {
606                         updateProperties(resource);
607                         resourceConsumed = updatePropertiesDone;
608                     }
609                 }
610                 if (!updateDependenciesDone) {
611                     resourceConsumed = true;
612                     cancelTask(updateDependenciesTask, false);
613                     updateDependenciesTask = scheduler.submit(() -> updateDependencies());
614                 }
615             } else if (ResourceType.SCENE == resource.getType()) {
616                 Resource cachedScene = sceneContributorsCache.get(incomingResourceId);
617                 if (Objects.nonNull(cachedScene)) {
618                     Setters.setResource(resource, cachedScene);
619                     resourceConsumed = updateChannels(resource);
620                     sceneContributorsCache.put(incomingResourceId, resource);
621                 }
622             } else {
623                 Resource cachedService = serviceContributorsCache.get(incomingResourceId);
624                 if (Objects.nonNull(cachedService)) {
625                     Setters.setResource(resource, cachedService);
626                     resourceConsumed = updateChannels(resource);
627                     serviceContributorsCache.put(incomingResourceId, resource);
628                     if (ResourceType.LIGHT == resource.getType() && !updateLightPropertiesDone) {
629                         updateLightProperties(resource);
630                     }
631                 }
632             }
633             if (resourceConsumed) {
634                 logger.debug("{} -> onResource() consumed resource {}", resourceId, resource);
635             }
636         }
637     }
638
639     /**
640      * Update the thing internal state depending on a full list of resources sent from the bridge. If the resourceType
641      * is SCENE then call updateScenes(), otherwise if the resource refers to this thing, consume it via onResource() as
642      * any other resource, or else if the resourceType nevertheless matches the thing type, set the thing state offline.
643      *
644      * @param resourceType the type of the resources in the list.
645      * @param fullResources the full list of resources of the given type.
646      */
647     public void onResourcesList(ResourceType resourceType, List<Resource> fullResources) {
648         if (resourceType == ResourceType.SCENE) {
649             updateSceneContributors(fullResources);
650         } else {
651             fullResources.stream().filter(r -> resourceId.equals(r.getId())).findAny()
652                     .ifPresentOrElse(r -> onResource(r), () -> {
653                         if (resourceType == thisResource.getType()) {
654                             logger.debug("{} -> onResourcesList() configuration error: unknown resourceId", resourceId);
655                             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
656                                     "@text/offline.api2.conf-error.resource-id-bad");
657                         }
658                     });
659         }
660     }
661
662     /**
663      * Process the incoming Resource to initialize the alert channel.
664      *
665      * @param resource a Resource possibly with an Alerts element.
666      */
667     private void updateAlertChannel(Resource resource) {
668         Alerts alerts = resource.getAlerts();
669         if (Objects.nonNull(alerts)) {
670             List<StateOption> stateOptions = alerts.getActionValues().stream().map(action -> action.name())
671                     .map(actionId -> new StateOption(actionId, actionId)).collect(Collectors.toList());
672             if (!stateOptions.isEmpty()) {
673                 stateDescriptionProvider.setStateOptions(new ChannelUID(thing.getUID(), CHANNEL_2_ALERT), stateOptions);
674                 logger.debug("{} -> updateAlerts() found {} associated alerts", resourceId, stateOptions.size());
675             }
676         }
677     }
678
679     /**
680      * If this v2 thing has a matching v1 legacy thing in the system, then for each channel in the v1 thing that
681      * corresponds to an equivalent channel in this v2 thing, and for all items that are linked to the v1 channel,
682      * create a new channel/item link between that item and the respective v2 channel in this thing.
683      */
684     private void updateChannelItemLinksFromLegacy() {
685         if (!disposing) {
686             legacyLinkedChannelUIDs.forEach(legacyLinkedChannelUID -> {
687                 String targetChannelId = REPLICATE_CHANNEL_ID_MAP.get(legacyLinkedChannelUID.getId());
688                 if (Objects.nonNull(targetChannelId)) {
689                     Channel targetChannel = thing.getChannel(targetChannelId);
690                     if (Objects.nonNull(targetChannel)) {
691                         ChannelUID uid = targetChannel.getUID();
692                         itemChannelLinkRegistry.getLinkedItems(legacyLinkedChannelUID).forEach(linkedItem -> {
693                             String item = linkedItem.getName();
694                             if (!itemChannelLinkRegistry.isLinked(item, uid)) {
695                                 if (logger.isDebugEnabled()) {
696                                     logger.debug(
697                                             "{} -> updateChannelItemLinksFromLegacy() item:{} linked to channel:{}",
698                                             resourceId, item, uid);
699                                 } else {
700                                     logger.info("Item '{}' linked to thing '{}' channel '{}'", item, thing.getUID(),
701                                             targetChannelId);
702                                 }
703                                 itemChannelLinkRegistry.add(new ItemChannelLink(item, uid));
704                             }
705                         });
706                     }
707                 }
708             });
709             legacyLinkedChannelUIDs.clear();
710         }
711     }
712
713     /**
714      * Set the active list of channels by removing any that had initially been created by the thing XML declaration, but
715      * which in fact did not have data returned from the bridge i.e. channels which are not in the supportedChannelIdSet
716      *
717      * Also warn if there are channels in the supportedChannelIdSet set which are not in the thing.
718      *
719      * Adjusts the channel list so that only the highest level channel is available in the normal channel list. If a
720      * light supports the color channel, then it's brightness and switch can be commanded via the 'B' part of the HSB
721      * channel value. And if it supports the brightness channel the switch can be controlled via the brightness. So we
722      * can remove these lower level channels from the normal channel list.
723      *
724      * For more advanced applications, it is necessary to orthogonally command the color xy parameter, dimming
725      * parameter, and/or on/off parameter independently. So we add corresponding advanced level 'CHANNEL_2_BLAH_ONLY'
726      * channels for that purpose. Since they are advanced level, normal users should normally not be confused by them,
727      * yet advanced users can use them nevertheless.
728      */
729     private void updateChannelList() {
730         if (!disposing) {
731             synchronized (supportedChannelIdSet) {
732                 logger.debug("{} -> updateChannelList()", resourceId);
733
734                 if (supportedChannelIdSet.contains(CHANNEL_2_COLOR)) {
735                     supportedChannelIdSet.add(CHANNEL_2_COLOR_XY_ONLY);
736                     //
737                     supportedChannelIdSet.remove(CHANNEL_2_BRIGHTNESS);
738                     supportedChannelIdSet.add(CHANNEL_2_DIMMING_ONLY);
739                     //
740                     supportedChannelIdSet.remove(CHANNEL_2_SWITCH);
741                     supportedChannelIdSet.add(CHANNEL_2_ON_OFF_ONLY);
742                 }
743                 if (supportedChannelIdSet.contains(CHANNEL_2_BRIGHTNESS)) {
744                     supportedChannelIdSet.add(CHANNEL_2_DIMMING_ONLY);
745                     //
746                     supportedChannelIdSet.remove(CHANNEL_2_SWITCH);
747                     supportedChannelIdSet.add(CHANNEL_2_ON_OFF_ONLY);
748                 }
749                 if (supportedChannelIdSet.contains(CHANNEL_2_SWITCH)) {
750                     supportedChannelIdSet.add(CHANNEL_2_ON_OFF_ONLY);
751                 }
752
753                 /*
754                  * This binding creates its dynamic list of channels by a 'subtractive' method i.e. the full set of
755                  * channels is initially created from the thing type xml, and then for any channels where UndfType.NULL
756                  * data is returned, the respective channel is removed from the full list. However in seldom cases
757                  * UndfType.NULL may wrongly be returned, so we should log a warning here just in case.
758                  */
759                 if (logger.isDebugEnabled()) {
760                     supportedChannelIdSet.stream().filter(channelId -> Objects.isNull(thing.getChannel(channelId)))
761                             .forEach(channelId -> logger.debug(
762                                     "{} -> updateChannelList() required channel '{}' missing", resourceId, channelId));
763                 } else {
764                     supportedChannelIdSet.stream().filter(channelId -> Objects.isNull(thing.getChannel(channelId)))
765                             .forEach(channelId -> logger.warn(
766                                     "Thing '{}' is missing required channel '{}'. Please recreate the thing!",
767                                     thing.getUID(), channelId));
768                 }
769
770                 // get list of unused channels
771                 List<Channel> unusedChannels = thing.getChannels().stream()
772                         .filter(channel -> !supportedChannelIdSet.contains(channel.getUID().getId()))
773                         .collect(Collectors.toList());
774
775                 // remove any unused channels
776                 if (!unusedChannels.isEmpty()) {
777                     if (logger.isDebugEnabled()) {
778                         unusedChannels.stream().map(channel -> channel.getUID().getId())
779                                 .forEach(channelId -> logger.debug(
780                                         "{} -> updateChannelList() removing unused channel '{}'", resourceId,
781                                         channelId));
782                     }
783                     updateThing(editThing().withoutChannels(unusedChannels).build());
784                 }
785             }
786         }
787     }
788
789     /**
790      * Update the state of the existing channels.
791      *
792      * @param resource the Resource containing the new channel state.
793      * @return true if the channel was found and updated.
794      */
795     private boolean updateChannels(Resource resource) {
796         logger.debug("{} -> updateChannels() from resource {}", resourceId, resource);
797         boolean fullUpdate = resource.hasFullState();
798         switch (resource.getType()) {
799             case BUTTON:
800                 if (fullUpdate) {
801                     addSupportedChannel(CHANNEL_2_BUTTON_LAST_EVENT);
802                     controlIds.put(resource.getId(), resource.getControlId());
803                 } else {
804                     State buttonState = resource.getButtonEventState(controlIds);
805                     updateState(CHANNEL_2_BUTTON_LAST_EVENT, buttonState, fullUpdate);
806                 }
807                 break;
808
809             case DEVICE_POWER:
810                 updateState(CHANNEL_2_BATTERY_LEVEL, resource.getBatteryLevelState(), fullUpdate);
811                 updateState(CHANNEL_2_BATTERY_LOW, resource.getBatteryLowState(), fullUpdate);
812                 break;
813
814             case LIGHT:
815                 if (fullUpdate) {
816                     updateEffectChannel(resource);
817                 }
818                 updateState(CHANNEL_2_COLOR_TEMP_PERCENT, resource.getColorTemperaturePercentState(), fullUpdate);
819                 updateState(CHANNEL_2_COLOR_TEMP_ABSOLUTE, resource.getColorTemperatureAbsoluteState(), fullUpdate);
820                 updateState(CHANNEL_2_COLOR, resource.getColorState(), fullUpdate);
821                 updateState(CHANNEL_2_COLOR_XY_ONLY, resource.getColorXyState(), fullUpdate);
822                 updateState(CHANNEL_2_EFFECT, resource.getEffectState(), fullUpdate);
823                 // fall through for dimming and on/off related channels
824
825             case GROUPED_LIGHT:
826                 if (fullUpdate) {
827                     updateAlertChannel(resource);
828                 }
829                 updateState(CHANNEL_2_BRIGHTNESS, resource.getBrightnessState(), fullUpdate);
830                 updateState(CHANNEL_2_DIMMING_ONLY, resource.getDimmingState(), fullUpdate);
831                 updateState(CHANNEL_2_SWITCH, resource.getOnOffState(), fullUpdate);
832                 updateState(CHANNEL_2_ON_OFF_ONLY, resource.getOnOffState(), fullUpdate);
833                 updateState(CHANNEL_2_ALERT, resource.getAlertState(), fullUpdate);
834                 break;
835
836             case LIGHT_LEVEL:
837                 updateState(CHANNEL_2_LIGHT_LEVEL, resource.getLightLevelState(), fullUpdate);
838                 updateState(CHANNEL_2_LIGHT_LEVEL_ENABLED, resource.getEnabledState(), fullUpdate);
839                 break;
840
841             case MOTION:
842                 updateState(CHANNEL_2_MOTION, resource.getMotionState(), fullUpdate);
843                 updateState(CHANNEL_2_MOTION_ENABLED, resource.getEnabledState(), fullUpdate);
844                 break;
845
846             case RELATIVE_ROTARY:
847                 if (fullUpdate) {
848                     addSupportedChannel(CHANNEL_2_ROTARY_STEPS);
849                 } else {
850                     updateState(CHANNEL_2_ROTARY_STEPS, resource.getRotaryStepsState(), fullUpdate);
851                 }
852                 break;
853
854             case TEMPERATURE:
855                 updateState(CHANNEL_2_TEMPERATURE, resource.getTemperatureState(), fullUpdate);
856                 updateState(CHANNEL_2_TEMPERATURE_ENABLED, resource.getEnabledState(), fullUpdate);
857                 break;
858
859             case ZIGBEE_CONNECTIVITY:
860                 updateConnectivityState(resource);
861                 break;
862
863             case SCENE:
864                 updateState(CHANNEL_2_SCENE, resource.getSceneState(), fullUpdate);
865                 break;
866
867             default:
868                 return false;
869         }
870         if (thisResource.getType() == ResourceType.DEVICE) {
871             updateState(CHANNEL_2_LAST_UPDATED, new DateTimeType(), fullUpdate);
872         }
873         return true;
874     }
875
876     /**
877      * Check the Zigbee connectivity and set the thing online status accordingly. If the thing is offline then set all
878      * its channel states to undefined, otherwise execute a refresh command to update channels to the latest current
879      * state.
880      *
881      * @param resource a Resource that potentially contains the Zigbee connectivity state.
882      */
883     private void updateConnectivityState(Resource resource) {
884         ZigbeeStatus zigbeeStatus = resource.getZigbeeStatus();
885         if (Objects.nonNull(zigbeeStatus)) {
886             logger.debug("{} -> updateConnectivityState() thingStatus:{}, zigbeeStatus:{}", resourceId,
887                     thing.getStatus(), zigbeeStatus);
888             hasConnectivityIssue = zigbeeStatus != ZigbeeStatus.CONNECTED;
889             if (hasConnectivityIssue) {
890                 if (thing.getStatusInfo().getStatusDetail() != ThingStatusDetail.COMMUNICATION_ERROR) {
891                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE,
892                             "@text/offline.api2.comm-error.zigbee-connectivity-issue");
893                     supportedChannelIdSet.forEach(channelId -> updateState(channelId, UnDefType.UNDEF));
894                 }
895             } else if (thing.getStatus() != ThingStatus.ONLINE) {
896                 updateStatus(ThingStatus.ONLINE);
897                 // issue REFRESH command to update all channels
898                 Channel lastUpdateChannel = thing.getChannel(CHANNEL_2_LAST_UPDATED);
899                 if (Objects.nonNull(lastUpdateChannel)) {
900                     handleCommand(lastUpdateChannel.getUID(), RefreshType.REFRESH);
901                 }
902             }
903         }
904     }
905
906     /**
907      * Get all resources needed for building the thing state. Build the forward / reverse contributor lookup maps. Set
908      * up the final list of channels in the thing.
909      */
910     private synchronized void updateDependencies() {
911         if (!disposing && !updateDependenciesDone) {
912             logger.debug("{} -> updateDependencies()", resourceId);
913             try {
914                 if (!updatePropertiesDone) {
915                     logger.debug("{} -> updateDependencies() properties not initialized", resourceId);
916                     return;
917                 }
918                 if (!updateSceneContributorsDone && !updateSceneContributors()) {
919                     logger.debug("{} -> updateDependencies() scenes not initialized", resourceId);
920                     return;
921                 }
922                 updateLookups();
923                 updateServiceContributors();
924                 updateChannelList();
925                 updateChannelItemLinksFromLegacy();
926                 if (!hasConnectivityIssue) {
927                     updateStatus(ThingStatus.ONLINE);
928                 }
929                 updateDependenciesDone = true;
930             } catch (ApiException e) {
931                 logger.debug("{} -> updateDependencies() {}", resourceId, e.getMessage(), e);
932                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
933             } catch (AssetNotLoadedException e) {
934                 logger.debug("{} -> updateDependencies() {}", resourceId, e.getMessage(), e);
935                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
936                         "@text/offline.api2.conf-error.assets-not-loaded");
937             } catch (InterruptedException e) {
938             }
939         }
940     }
941
942     /**
943      * Process the incoming Resource to initialize the effects channel.
944      *
945      * @param resource a Resource possibly with an Effects element.
946      */
947     public void updateEffectChannel(Resource resource) {
948         Effects effects = resource.getEffects();
949         if (Objects.nonNull(effects)) {
950             List<StateOption> stateOptions = effects.getStatusValues().stream()
951                     .map(effect -> EffectType.of(effect).name()).map(effectId -> new StateOption(effectId, effectId))
952                     .collect(Collectors.toList());
953             if (!stateOptions.isEmpty()) {
954                 stateDescriptionProvider.setStateOptions(new ChannelUID(thing.getUID(), CHANNEL_2_EFFECT),
955                         stateOptions);
956                 logger.debug("{} -> updateEffects() found {} effects", resourceId, stateOptions.size());
957             }
958         }
959     }
960
961     /**
962      * Update the light properties.
963      *
964      * @param resource a Resource object containing the property data.
965      */
966     private synchronized void updateLightProperties(Resource resource) {
967         if (!disposing && !updateLightPropertiesDone) {
968             logger.debug("{} -> updateLightProperties()", resourceId);
969
970             Dimming dimming = resource.getDimming();
971             thing.setProperty(PROPERTY_DIMMING_RANGE, Objects.nonNull(dimming) ? dimming.toPropertyValue() : null);
972
973             MirekSchema mirekSchema = resource.getMirekSchema();
974             thing.setProperty(PROPERTY_COLOR_TEMP_RANGE,
975                     Objects.nonNull(mirekSchema) ? mirekSchema.toPropertyValue() : null);
976
977             ColorXy colorXy = resource.getColorXy();
978             Gamut2 gamut = Objects.nonNull(colorXy) ? colorXy.getGamut2() : null;
979             thing.setProperty(PROPERTY_COLOR_GAMUT, Objects.nonNull(gamut) ? gamut.toPropertyValue() : null);
980
981             updateLightPropertiesDone = true;
982         }
983     }
984
985     /**
986      * Initialize the lookup maps of resources that contribute to the thing state.
987      */
988     private void updateLookups() {
989         if (!disposing) {
990             logger.debug("{} -> updateLookups()", resourceId);
991             // get supported services
992             List<ResourceReference> services = thisResource.getServiceReferences();
993
994             // add supported services to contributorsCache
995             serviceContributorsCache.clear();
996             serviceContributorsCache.putAll(services.stream()
997                     .collect(Collectors.toMap(ResourceReference::getId, r -> new Resource(r.getType()))));
998
999             // add supported services to commandResourceIds
1000             commandResourceIds.clear();
1001             commandResourceIds.putAll(services.stream() // use a 'mergeFunction' to prevent duplicates
1002                     .collect(Collectors.toMap(ResourceReference::getType, ResourceReference::getId, (r1, r2) -> r1)));
1003         }
1004     }
1005
1006     /**
1007      * Update the primary device properties.
1008      *
1009      * @param resource a Resource object containing the property data.
1010      */
1011     private synchronized void updateProperties(Resource resource) {
1012         if (!disposing && !updatePropertiesDone) {
1013             logger.debug("{} -> updateProperties()", resourceId);
1014             Map<String, String> properties = new HashMap<>(thing.getProperties());
1015
1016             // resource data
1017             properties.put(PROPERTY_RESOURCE_TYPE, thisResource.getType().toString());
1018             properties.put(PROPERTY_RESOURCE_NAME, thisResource.getName());
1019
1020             // owner information
1021             ResourceReference owner = thisResource.getOwner();
1022             if (Objects.nonNull(owner)) {
1023                 String ownerId = owner.getId();
1024                 if (Objects.nonNull(ownerId)) {
1025                     properties.put(PROPERTY_OWNER, ownerId);
1026                 }
1027                 ResourceType ownerType = owner.getType();
1028                 properties.put(PROPERTY_OWNER_TYPE, ownerType.toString());
1029             }
1030
1031             // metadata
1032             MetaData metaData = thisResource.getMetaData();
1033             if (Objects.nonNull(metaData)) {
1034                 properties.put(PROPERTY_RESOURCE_ARCHETYPE, metaData.getArchetype().toString());
1035             }
1036
1037             // product data
1038             ProductData productData = thisResource.getProductData();
1039             if (Objects.nonNull(productData)) {
1040                 String modelId = productData.getModelId();
1041
1042                 // standard properties
1043                 properties.put(PROPERTY_RESOURCE_ID, resourceId);
1044                 properties.put(Thing.PROPERTY_MODEL_ID, modelId);
1045                 properties.put(Thing.PROPERTY_VENDOR, productData.getManufacturerName());
1046                 properties.put(Thing.PROPERTY_FIRMWARE_VERSION, productData.getSoftwareVersion());
1047                 String hardwarePlatformType = productData.getHardwarePlatformType();
1048                 if (Objects.nonNull(hardwarePlatformType)) {
1049                     properties.put(Thing.PROPERTY_HARDWARE_VERSION, hardwarePlatformType);
1050                 }
1051
1052                 // hue specific properties
1053                 properties.put(PROPERTY_PRODUCT_NAME, productData.getProductName());
1054                 properties.put(PROPERTY_PRODUCT_ARCHETYPE, productData.getProductArchetype().toString());
1055                 properties.put(PROPERTY_PRODUCT_CERTIFIED, productData.getCertified().toString());
1056
1057                 // Check device for needed work-arounds.
1058                 if (LK_WISER_DIMMER_MODEL_ID.equals(modelId)) {
1059                     // Apply transition time as a workaround for LK Wiser Dimmer firmware bug.
1060                     // Additional details here: https://techblog.vindvejr.dk/?p=455
1061                     applyOffTransitionWorkaround = true;
1062                     logger.debug("{} -> enabling work-around for turning off LK Wiser Dimmer", resourceId);
1063                 }
1064             }
1065
1066             thing.setProperties(properties);
1067             updatePropertiesDone = true;
1068         }
1069     }
1070
1071     /**
1072      * Execute an HTTP GET command to fetch the resources data for the referenced resource.
1073      *
1074      * @param reference to the required resource.
1075      * @throws ApiException if a communication error occurred.
1076      * @throws AssetNotLoadedException if one of the assets is not loaded.
1077      * @throws InterruptedException
1078      */
1079     private void updateResource(ResourceReference reference)
1080             throws ApiException, AssetNotLoadedException, InterruptedException {
1081         if (!disposing) {
1082             logger.debug("{} -> updateResource() from resource {}", resourceId, reference);
1083             getBridgeHandler().getResources(reference).getResources().stream()
1084                     .forEach(resource -> onResource(resource));
1085         }
1086     }
1087
1088     /**
1089      * Fetch the full list of scenes from the bridge, and call updateSceneContributors(List<Resource> allScenes)
1090      *
1091      * @throws ApiException if a communication error occurred.
1092      * @throws AssetNotLoadedException if one of the assets is not loaded.
1093      * @throws InterruptedException
1094      */
1095     public boolean updateSceneContributors() throws ApiException, AssetNotLoadedException, InterruptedException {
1096         if (!disposing && !updateSceneContributorsDone) {
1097             ResourceReference scenesReference = new ResourceReference().setType(ResourceType.SCENE);
1098             updateSceneContributors(getBridgeHandler().getResources(scenesReference).getResources());
1099         }
1100         return updateSceneContributorsDone;
1101     }
1102
1103     /**
1104      * Process the incoming list of scene resources to find those scenes which contribute to this thing. And if there
1105      * are any, include a scene channel in the supported channel list, and populate its respective state options.
1106      *
1107      * @param allScenes the full list of scene resources.
1108      */
1109     public synchronized boolean updateSceneContributors(List<Resource> allScenes) {
1110         if (!disposing && !updateSceneContributorsDone) {
1111             sceneContributorsCache.clear();
1112             sceneResourceIds.clear();
1113
1114             ResourceReference thisReference = getResourceReference();
1115             List<Resource> scenes = allScenes.stream().filter(s -> thisReference.equals(s.getGroup()))
1116                     .collect(Collectors.toList());
1117
1118             if (!scenes.isEmpty()) {
1119                 sceneContributorsCache.putAll(scenes.stream().collect(Collectors.toMap(s -> s.getId(), s -> s)));
1120                 sceneResourceIds.putAll(scenes.stream().collect(Collectors.toMap(s -> s.getName(), s -> s.getId())));
1121
1122                 State state = scenes.stream().filter(s -> s.getSceneActive().orElse(false)).map(s -> s.getSceneState())
1123                         .findAny().orElse(UnDefType.UNDEF);
1124                 updateState(CHANNEL_2_SCENE, state, true);
1125
1126                 stateDescriptionProvider.setStateOptions(new ChannelUID(thing.getUID(), CHANNEL_2_SCENE), scenes
1127                         .stream().map(s -> s.getName()).map(n -> new StateOption(n, n)).collect(Collectors.toList()));
1128
1129                 logger.debug("{} -> updateSceneContributors() found {} scenes", resourceId, scenes.size());
1130             }
1131             updateSceneContributorsDone = true;
1132         }
1133         return updateSceneContributorsDone;
1134     }
1135
1136     /**
1137      * Execute a series of HTTP GET commands to fetch the resource data for all service resources that contribute to the
1138      * thing state.
1139      *
1140      * @throws ApiException if a communication error occurred.
1141      * @throws AssetNotLoadedException if one of the assets is not loaded.
1142      * @throws InterruptedException
1143      */
1144     private void updateServiceContributors() throws ApiException, AssetNotLoadedException, InterruptedException {
1145         if (!disposing) {
1146             logger.debug("{} -> updateServiceContributors() called for {} contributors", resourceId,
1147                     serviceContributorsCache.size());
1148             ResourceReference reference = new ResourceReference();
1149             for (var entry : serviceContributorsCache.entrySet()) {
1150                 updateResource(reference.setId(entry.getKey()).setType(entry.getValue().getType()));
1151             }
1152         }
1153     }
1154
1155     /**
1156      * Update the channel state, and if appropriate add the channel ID to the set of supportedChannelIds. Calls either
1157      * OH core updateState() or triggerChannel() methods depending on the channel kind.
1158      *
1159      * Note: the particular 'UnDefType.UNDEF' value of the state argument is used to specially indicate the undefined
1160      * state, but yet that its channel shall nevertheless continue to be present in the thing.
1161      *
1162      * @param channelID the id of the channel.
1163      * @param state the new state of the channel.
1164      * @param fullUpdate if true always update the channel, otherwise only update if state is not 'UNDEF'.
1165      */
1166     private void updateState(String channelID, State state, boolean fullUpdate) {
1167         boolean isDefined = state != UnDefType.NULL;
1168         Channel channel = thing.getChannel(channelID);
1169
1170         if ((fullUpdate || isDefined) && Objects.nonNull(channel)) {
1171             logger.debug("{} -> updateState() '{}' update with '{}' (fullUpdate:{}, isDefined:{})", resourceId,
1172                     channelID, state, fullUpdate, isDefined);
1173
1174             switch (channel.getKind()) {
1175                 case STATE:
1176                     updateState(channelID, state);
1177                     break;
1178
1179                 case TRIGGER:
1180                     if (state instanceof DecimalType) {
1181                         triggerChannel(channelID, String.valueOf(((DecimalType) state).intValue()));
1182                     }
1183             }
1184         }
1185         if (fullUpdate && isDefined) {
1186             addSupportedChannel(channelID);
1187         }
1188     }
1189
1190     /**
1191      * Check if a PROPERTY_LEGACY_THING_UID value was set by the discovery process, and if so, clone the legacy thing's
1192      * settings into this thing.
1193      */
1194     private void updateThingFromLegacy() {
1195         if (isInitialized()) {
1196             logger.warn("Cannot update thing '{}' from legacy thing since handler already initialized.",
1197                     thing.getUID());
1198             return;
1199         }
1200         Map<String, String> properties = thing.getProperties();
1201         String legacyThingUID = properties.get(PROPERTY_LEGACY_THING_UID);
1202         if (Objects.nonNull(legacyThingUID)) {
1203             Thing legacyThing = thingRegistry.get(new ThingUID(legacyThingUID));
1204             if (Objects.nonNull(legacyThing)) {
1205                 ThingBuilder editBuilder = editThing();
1206
1207                 String location = legacyThing.getLocation();
1208                 if (Objects.nonNull(location) && !location.isBlank()) {
1209                     editBuilder = editBuilder.withLocation(location);
1210                 }
1211
1212                 // save list of legacyLinkedChannelUIDs for use after channel list is initialised
1213                 legacyLinkedChannelUIDs.clear();
1214                 legacyLinkedChannelUIDs.addAll(legacyThing.getChannels().stream().map(Channel::getUID)
1215                         .filter(uid -> REPLICATE_CHANNEL_ID_MAP.containsKey(uid.getId())
1216                                 && itemChannelLinkRegistry.isLinked(uid))
1217                         .collect(Collectors.toList()));
1218
1219                 Map<String, String> newProperties = new HashMap<>(properties);
1220                 newProperties.remove(PROPERTY_LEGACY_THING_UID);
1221
1222                 updateThing(editBuilder.withProperties(newProperties).build());
1223             }
1224         }
1225     }
1226 }