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