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