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