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