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