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