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