2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.hue.internal.handler;
15 import static org.openhab.binding.hue.internal.HueBindingConstants.*;
17 import java.math.BigDecimal;
18 import java.time.Duration;
19 import java.time.Instant;
20 import java.util.Collection;
21 import java.util.HashMap;
22 import java.util.HashSet;
23 import java.util.List;
25 import java.util.Objects;
27 import java.util.concurrent.ConcurrentHashMap;
28 import java.util.concurrent.CopyOnWriteArrayList;
29 import java.util.concurrent.Future;
30 import java.util.concurrent.TimeUnit;
31 import java.util.stream.Collectors;
33 import org.eclipse.jdt.annotation.NonNullByDefault;
34 import org.eclipse.jdt.annotation.Nullable;
35 import org.openhab.binding.hue.internal.action.DynamicsActions;
36 import org.openhab.binding.hue.internal.config.Clip2ThingConfig;
37 import org.openhab.binding.hue.internal.dto.clip2.Alerts;
38 import org.openhab.binding.hue.internal.dto.clip2.ColorXy;
39 import org.openhab.binding.hue.internal.dto.clip2.Dimming;
40 import org.openhab.binding.hue.internal.dto.clip2.Effects;
41 import org.openhab.binding.hue.internal.dto.clip2.Gamut2;
42 import org.openhab.binding.hue.internal.dto.clip2.MetaData;
43 import org.openhab.binding.hue.internal.dto.clip2.MirekSchema;
44 import org.openhab.binding.hue.internal.dto.clip2.ProductData;
45 import org.openhab.binding.hue.internal.dto.clip2.Resource;
46 import org.openhab.binding.hue.internal.dto.clip2.ResourceReference;
47 import org.openhab.binding.hue.internal.dto.clip2.enums.ActionType;
48 import org.openhab.binding.hue.internal.dto.clip2.enums.EffectType;
49 import org.openhab.binding.hue.internal.dto.clip2.enums.RecallAction;
50 import org.openhab.binding.hue.internal.dto.clip2.enums.ResourceType;
51 import org.openhab.binding.hue.internal.dto.clip2.enums.ZigbeeStatus;
52 import org.openhab.binding.hue.internal.dto.clip2.helper.Setters;
53 import org.openhab.binding.hue.internal.exceptions.ApiException;
54 import org.openhab.binding.hue.internal.exceptions.AssetNotLoadedException;
55 import org.openhab.core.library.types.DateTimeType;
56 import org.openhab.core.library.types.DecimalType;
57 import org.openhab.core.library.types.HSBType;
58 import org.openhab.core.library.types.IncreaseDecreaseType;
59 import org.openhab.core.library.types.OnOffType;
60 import org.openhab.core.library.types.PercentType;
61 import org.openhab.core.library.types.QuantityType;
62 import org.openhab.core.library.types.StringType;
63 import org.openhab.core.library.unit.MetricPrefix;
64 import org.openhab.core.library.unit.Units;
65 import org.openhab.core.thing.Bridge;
66 import org.openhab.core.thing.Channel;
67 import org.openhab.core.thing.ChannelUID;
68 import org.openhab.core.thing.Thing;
69 import org.openhab.core.thing.ThingRegistry;
70 import org.openhab.core.thing.ThingStatus;
71 import org.openhab.core.thing.ThingStatusDetail;
72 import org.openhab.core.thing.ThingTypeUID;
73 import org.openhab.core.thing.ThingUID;
74 import org.openhab.core.thing.binding.BaseThingHandler;
75 import org.openhab.core.thing.binding.BridgeHandler;
76 import org.openhab.core.thing.binding.ThingHandlerService;
77 import org.openhab.core.thing.binding.builder.ThingBuilder;
78 import org.openhab.core.thing.link.ItemChannelLink;
79 import org.openhab.core.thing.link.ItemChannelLinkRegistry;
80 import org.openhab.core.types.Command;
81 import org.openhab.core.types.RefreshType;
82 import org.openhab.core.types.State;
83 import org.openhab.core.types.StateOption;
84 import org.openhab.core.types.UnDefType;
85 import org.slf4j.Logger;
86 import org.slf4j.LoggerFactory;
89 * Handler for things based on CLIP 2 'device', 'room', or 'zone resources.
91 * @author Andrew Fiddian-Green - Initial contribution.
94 public class Clip2ThingHandler extends BaseThingHandler {
96 public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Set.of(THING_TYPE_DEVICE, THING_TYPE_ROOM,
99 private static final Duration DYNAMICS_ACTIVE_WINDOW = Duration.ofSeconds(10);
101 private final Logger logger = LoggerFactory.getLogger(Clip2ThingHandler.class);
104 * A map of service Resources whose state contributes to the overall state of this thing. It is a map between the
105 * resource ID (string) and a Resource object containing the last known state. e.g. a DEVICE thing may support a
106 * LIGHT service whose Resource contributes to its overall state, or a ROOM or ZONE thing may support a
107 * GROUPED_LIGHT service whose Resource contributes to the its overall state.
109 private final Map<String, Resource> serviceContributorsCache = new ConcurrentHashMap<>();
112 * A map of Resource IDs which are targets for commands to be sent. It is a map between the type of command
113 * (ResourcesType) and the resource ID to which the command shall be sent. e.g. a LIGHT 'on' command shall be sent
114 * to the respective LIGHT resource ID.
116 private final Map<ResourceType, String> commandResourceIds = new ConcurrentHashMap<>();
119 * Button devices contain one or more physical buttons, each of which is represented by a BUTTON Resource with its
120 * own unique resource ID, and a respective controlId that indicates which button it is in the device. e.g. a dimmer
121 * pad has four buttons (controlId's 1..4) each represented by a BUTTON Resource with a unique resource ID. This is
122 * a map between the resource ID and its respective controlId.
124 private final Map<String, Integer> controlIds = new ConcurrentHashMap<>();
127 * The set of channel IDs that are supported by this thing. e.g. an on/off light may support 'switch' and
128 * 'zigbeeStatus' channels, whereas a complex light may support 'switch', 'brightness', 'color', 'color temperature'
129 * and 'zigbeeStatus' channels.
131 private final Set<String> supportedChannelIdSet = new HashSet<>();
134 * A map of scene IDs and respective scene Resources for the scenes that contribute to and command this thing. It is
135 * a map between the resource ID (string) and a Resource object containing the scene's last known state.
137 private final Map<String, Resource> sceneContributorsCache = new ConcurrentHashMap<>();
140 * A map of scene names versus Resource IDs for the scenes that contribute to and command this thing. e.g. a command
141 * for a scene named 'Energize' shall be sent to the respective SCENE resource ID.
143 private final Map<String, String> sceneResourceIds = new ConcurrentHashMap<>();
146 * A list of API v1 thing channel UIDs that are linked to items. It is used in the process of replicating the
147 * Item/Channel links from a legacy v1 thing to this API v2 thing.
149 private final List<ChannelUID> legacyLinkedChannelUIDs = new CopyOnWriteArrayList<>();
151 private final ThingRegistry thingRegistry;
152 private final ItemChannelLinkRegistry itemChannelLinkRegistry;
153 private final Clip2StateDescriptionProvider stateDescriptionProvider;
155 private String resourceId = "?";
156 private Resource thisResource;
157 private Duration dynamicsDuration = Duration.ZERO;
158 private Instant dynamicsExpireTime = Instant.MIN;
160 private boolean disposing;
161 private boolean hasConnectivityIssue;
162 private boolean updateSceneContributorsDone;
163 private boolean updateLightPropertiesDone;
164 private boolean updatePropertiesDone;
165 private boolean updateDependenciesDone;
167 private @Nullable Future<?> alertResetTask;
168 private @Nullable Future<?> dynamicsResetTask;
169 private @Nullable Future<?> updateDependenciesTask;
170 private @Nullable Future<?> updateServiceContributorsTask;
172 public Clip2ThingHandler(Thing thing, Clip2StateDescriptionProvider stateDescriptionProvider,
173 ThingRegistry thingRegistry, ItemChannelLinkRegistry itemChannelLinkRegistry) {
176 ThingTypeUID thingTypeUID = thing.getThingTypeUID();
177 if (THING_TYPE_DEVICE.equals(thingTypeUID)) {
178 thisResource = new Resource(ResourceType.DEVICE);
179 } else if (THING_TYPE_ROOM.equals(thingTypeUID)) {
180 thisResource = new Resource(ResourceType.ROOM);
181 } else if (THING_TYPE_ZONE.equals(thingTypeUID)) {
182 thisResource = new Resource(ResourceType.ZONE);
184 throw new IllegalArgumentException("Wrong thing type " + thingTypeUID.getAsString());
187 this.thingRegistry = thingRegistry;
188 this.itemChannelLinkRegistry = itemChannelLinkRegistry;
189 this.stateDescriptionProvider = stateDescriptionProvider;
193 * Add a channel ID to the supportedChannelIdSet set. If the channel supports dynamics (timed transitions) then add
194 * the respective channel as well.
196 * @param channelId the channel ID to add.
198 private void addSupportedChannel(String channelId) {
199 if (!disposing && !updateDependenciesDone) {
200 synchronized (supportedChannelIdSet) {
201 logger.debug("{} -> addSupportedChannel() '{}' added to supported channel set", resourceId, channelId);
202 supportedChannelIdSet.add(channelId);
203 if (DYNAMIC_CHANNELS.contains(channelId)) {
204 clearDynamicsChannel();
211 * Cancel the given task.
213 * @param cancelTask the task to be cancelled (may be null)
214 * @param mayInterrupt allows cancel() to interrupt the thread.
216 private void cancelTask(@Nullable Future<?> cancelTask, boolean mayInterrupt) {
217 if (Objects.nonNull(cancelTask)) {
218 cancelTask.cancel(mayInterrupt);
223 * Clear the dynamics channel parameters.
225 private void clearDynamicsChannel() {
226 dynamicsExpireTime = Instant.MIN;
227 dynamicsDuration = Duration.ZERO;
228 updateState(CHANNEL_2_DYNAMICS, new QuantityType<>(0, MetricPrefix.MILLI(Units.SECOND)), true);
232 public void dispose() {
233 logger.debug("{} -> dispose()", resourceId);
235 cancelTask(alertResetTask, true);
236 cancelTask(dynamicsResetTask, true);
237 cancelTask(updateDependenciesTask, true);
238 cancelTask(updateServiceContributorsTask, true);
239 alertResetTask = null;
240 dynamicsResetTask = null;
241 updateDependenciesTask = null;
242 updateServiceContributorsTask = null;
243 legacyLinkedChannelUIDs.clear();
244 sceneContributorsCache.clear();
245 sceneResourceIds.clear();
246 supportedChannelIdSet.clear();
247 commandResourceIds.clear();
248 serviceContributorsCache.clear();
253 * Get the bridge handler.
255 * @throws AssetNotLoadedException if the handler does not exist.
257 private Clip2BridgeHandler getBridgeHandler() throws AssetNotLoadedException {
258 Bridge bridge = getBridge();
259 if (Objects.nonNull(bridge)) {
260 BridgeHandler handler = bridge.getHandler();
261 if (handler instanceof Clip2BridgeHandler) {
262 return (Clip2BridgeHandler) handler;
265 throw new AssetNotLoadedException("Bridge handler missing");
269 * Do a double lookup to get the cached resource that matches the given ResourceType.
271 * @param resourceType the type to search for.
272 * @return the Resource, or null if not found.
274 private @Nullable Resource getCachedResource(ResourceType resourceType) {
275 String commandResourceId = commandResourceIds.get(resourceType);
276 return Objects.nonNull(commandResourceId) ? serviceContributorsCache.get(commandResourceId) : null;
280 * Return a ResourceReference to this handler's resource.
282 * @return a ResourceReference instance.
284 public ResourceReference getResourceReference() {
285 return new ResourceReference().setId(resourceId).setType(thisResource.getType());
289 * Register the 'DynamicsAction' service.
292 public Collection<Class<? extends ThingHandlerService>> getServices() {
293 return Set.of(DynamicsActions.class);
297 public void handleCommand(ChannelUID channelUID, Command commandParam) {
298 if (RefreshType.REFRESH.equals(commandParam)) {
299 if ((thing.getStatus() == ThingStatus.ONLINE) && updateDependenciesDone) {
300 Future<?> task = updateServiceContributorsTask;
301 if (Objects.isNull(task) || !task.isDone()) {
302 cancelTask(updateServiceContributorsTask, false);
303 updateServiceContributorsTask = scheduler.schedule(() -> {
305 updateServiceContributors();
306 } catch (ApiException | AssetNotLoadedException e) {
307 logger.debug("{} -> handleCommand() error {}", resourceId, e.getMessage(), e);
308 } catch (InterruptedException e) {
310 }, 3, TimeUnit.SECONDS);
316 Channel channel = thing.getChannel(channelUID);
317 if (channel == null) {
318 if (logger.isDebugEnabled()) {
319 logger.debug("{} -> handleCommand() channelUID:{} does not exist", resourceId, channelUID);
322 logger.warn("Command received for channel '{}' which is not in thing '{}'.", channelUID,
328 ResourceType lightResourceType = thisResource.getType() == ResourceType.DEVICE ? ResourceType.LIGHT
329 : ResourceType.GROUPED_LIGHT;
331 Resource putResource = null;
332 String putResourceId = null;
333 Command command = commandParam;
334 String channelId = channelUID.getId();
335 Resource cache = getCachedResource(lightResourceType);
338 case CHANNEL_2_ALERT:
339 putResource = Setters.setAlert(new Resource(lightResourceType), command, cache);
340 cancelTask(alertResetTask, false);
341 alertResetTask = scheduler.schedule(
342 () -> updateState(channelUID, new StringType(ActionType.NO_ACTION.name())), 10,
346 case CHANNEL_2_EFFECT:
347 putResource = Setters.setEffect(new Resource(lightResourceType), command, cache);
348 putResource.setOnOff(OnOffType.ON);
351 case CHANNEL_2_COLOR_TEMP_PERCENT:
352 if (command instanceof IncreaseDecreaseType) {
353 if (Objects.nonNull(cache)) {
354 State current = cache.getColorTemperaturePercentState();
355 if (current instanceof PercentType) {
356 int sign = IncreaseDecreaseType.INCREASE == command ? 1 : -1;
357 int percent = ((PercentType) current).intValue() + (sign * (int) Resource.PERCENT_DELTA);
358 command = new PercentType(Math.min(100, Math.max(0, percent)));
361 } else if (command instanceof OnOffType) {
362 command = OnOffType.OFF == command ? PercentType.ZERO : PercentType.HUNDRED;
364 putResource = Setters.setColorTemperaturePercent(new Resource(lightResourceType), command, cache);
367 case CHANNEL_2_COLOR_TEMP_ABSOLUTE:
368 putResource = Setters.setColorTemperatureAbsolute(new Resource(lightResourceType), command, cache);
371 case CHANNEL_2_COLOR:
372 putResource = new Resource(lightResourceType);
373 if (command instanceof HSBType) {
374 HSBType color = ((HSBType) command);
375 putResource = Setters.setColorXy(putResource, color, cache);
376 command = color.getBrightness();
378 // NB fall through for handling of brightness and switch related commands !!
380 case CHANNEL_2_BRIGHTNESS:
381 putResource = Objects.nonNull(putResource) ? putResource : new Resource(lightResourceType);
382 if (command instanceof IncreaseDecreaseType) {
383 if (Objects.nonNull(cache)) {
384 State current = cache.getBrightnessState();
385 if (current instanceof PercentType) {
386 int sign = IncreaseDecreaseType.INCREASE == command ? 1 : -1;
387 double percent = ((PercentType) current).doubleValue() + (sign * Resource.PERCENT_DELTA);
388 command = new PercentType(new BigDecimal(Math.min(100f, Math.max(0f, percent)),
389 Resource.PERCENT_MATH_CONTEXT));
393 if (command instanceof PercentType) {
394 PercentType brightness = (PercentType) command;
395 putResource = Setters.setDimming(putResource, brightness, cache);
396 Double minDimLevel = Objects.nonNull(cache) ? cache.getMinimumDimmingLevel() : null;
397 minDimLevel = Objects.nonNull(minDimLevel) ? minDimLevel : Dimming.DEFAULT_MINIMUM_DIMMIMG_LEVEL;
398 command = OnOffType.from(brightness.doubleValue() >= minDimLevel);
400 // NB fall through for handling of switch related commands !!
402 case CHANNEL_2_SWITCH:
403 putResource = Objects.nonNull(putResource) ? putResource : new Resource(lightResourceType);
404 putResource.setOnOff(command);
407 case CHANNEL_2_COLOR_XY_ONLY:
408 putResource = Setters.setColorXy(new Resource(lightResourceType), command, cache);
411 case CHANNEL_2_DIMMING_ONLY:
412 putResource = Setters.setDimming(new Resource(lightResourceType), command, cache);
415 case CHANNEL_2_ON_OFF_ONLY:
416 putResource = new Resource(lightResourceType).setOnOff(command);
419 case CHANNEL_2_TEMPERATURE_ENABLED:
420 putResource = new Resource(ResourceType.TEMPERATURE).setEnabled(command);
423 case CHANNEL_2_MOTION_ENABLED:
424 putResource = new Resource(ResourceType.MOTION).setEnabled(command);
427 case CHANNEL_2_LIGHT_LEVEL_ENABLED:
428 putResource = new Resource(ResourceType.LIGHT_LEVEL).setEnabled(command);
431 case CHANNEL_2_SCENE:
432 if (command instanceof StringType) {
433 putResourceId = sceneResourceIds.get(((StringType) command).toString());
434 if (Objects.nonNull(putResourceId)) {
435 putResource = new Resource(ResourceType.SCENE).setRecallAction(RecallAction.ACTIVE);
440 case CHANNEL_2_DYNAMICS:
441 Duration clearAfter = Duration.ZERO;
442 if (command instanceof QuantityType<?>) {
443 QuantityType<?> durationMs = ((QuantityType<?>) command).toUnit(MetricPrefix.MILLI(Units.SECOND));
444 if (Objects.nonNull(durationMs) && durationMs.longValue() > 0) {
445 Duration duration = Duration.ofMillis(durationMs.longValue());
446 dynamicsDuration = duration;
447 dynamicsExpireTime = Instant.now().plus(DYNAMICS_ACTIVE_WINDOW);
448 clearAfter = DYNAMICS_ACTIVE_WINDOW;
449 logger.debug("{} -> handleCommand() dynamics setting {} valid for {}", resourceId, duration,
453 cancelTask(dynamicsResetTask, false);
454 dynamicsResetTask = scheduler.schedule(() -> clearDynamicsChannel(), clearAfter.toMillis(),
455 TimeUnit.MILLISECONDS);
459 if (logger.isDebugEnabled()) {
460 logger.debug("{} -> handleCommand() channelUID:{} unknown", resourceId, channelUID);
462 logger.warn("Command received for unknown channel '{}'.", channelUID);
467 if (putResource == null) {
468 if (logger.isDebugEnabled()) {
469 logger.debug("{} -> handleCommand() command:{} not supported on channelUID:{}", resourceId, command,
472 logger.warn("Command '{}' is not supported on channel '{}'.", command, channelUID);
477 putResourceId = Objects.nonNull(putResourceId) ? putResourceId : commandResourceIds.get(putResource.getType());
478 if (putResourceId == null) {
479 if (logger.isDebugEnabled()) {
481 "{} -> handleCommand() channelUID:{}, command:{}, putResourceType:{} => missing resource ID",
482 resourceId, channelUID, command, putResource.getType());
484 logger.warn("Command '{}' for channel '{}' cannot be processed by thing '{}'.", command, channelUID,
490 if (DYNAMIC_CHANNELS.contains(channelId)) {
491 if (Instant.now().isBefore(dynamicsExpireTime) && !dynamicsDuration.isZero()
492 && !dynamicsDuration.isNegative()) {
493 if (ResourceType.SCENE == putResource.getType()) {
494 putResource.setRecallDuration(dynamicsDuration);
496 putResource.setDynamicsDuration(dynamicsDuration);
501 putResource.setId(putResourceId);
502 logger.debug("{} -> handleCommand() put resource {}", resourceId, putResource);
505 getBridgeHandler().putResource(putResource);
506 } catch (ApiException | AssetNotLoadedException e) {
507 if (logger.isDebugEnabled()) {
508 logger.debug("{} -> handleCommand() error {}", resourceId, e.getMessage(), e);
510 logger.warn("Command '{}' for thing '{}', channel '{}' failed with error '{}'.", command,
511 thing.getUID(), channelUID, e.getMessage());
513 } catch (InterruptedException e) {
518 * Handle a 'dynamics' command for the given channel ID for the given dynamics duration.
520 * @param channelId the ID of the target channel.
521 * @param command the new target state.
522 * @param duration the transition duration.
524 public synchronized void handleDynamicsCommand(String channelId, Command command, QuantityType<?> duration) {
525 if (DYNAMIC_CHANNELS.contains(channelId)) {
526 Channel dynamicsChannel = thing.getChannel(CHANNEL_2_DYNAMICS);
527 Channel targetChannel = thing.getChannel(channelId);
528 if (Objects.nonNull(dynamicsChannel) && Objects.nonNull(targetChannel)) {
529 logger.debug("{} - handleDynamicsCommand() channelId:{}, command:{}, duration:{}", resourceId,
530 channelId, command, duration);
531 handleCommand(dynamicsChannel.getUID(), duration);
532 handleCommand(targetChannel.getUID(), command);
536 logger.warn("Dynamics command '{}' for thing '{}', channel '{}' and duration'{}' failed.", command,
537 thing.getUID(), channelId, duration);
541 public void initialize() {
542 Clip2ThingConfig config = getConfigAs(Clip2ThingConfig.class);
544 String resourceId = config.resourceId;
545 if (resourceId.isBlank()) {
546 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
547 "@text/offline.api2.conf-error.resource-id-bad");
550 thisResource.setId(resourceId);
551 this.resourceId = resourceId;
552 logger.debug("{} -> initialize()", resourceId);
554 updateThingFromLegacy();
555 updateStatus(ThingStatus.UNKNOWN);
557 dynamicsDuration = Duration.ZERO;
558 dynamicsExpireTime = Instant.MIN;
561 hasConnectivityIssue = false;
562 updatePropertiesDone = false;
563 updateDependenciesDone = false;
564 updateLightPropertiesDone = false;
565 updateSceneContributorsDone = false;
567 Bridge bridge = getBridge();
568 if (Objects.nonNull(bridge)) {
569 BridgeHandler bridgeHandler = bridge.getHandler();
570 if (bridgeHandler instanceof Clip2BridgeHandler) {
571 ((Clip2BridgeHandler) bridgeHandler).childInitialized();
577 * Update the channel state depending on a new resource sent from the bridge.
579 * @param resource a Resource object containing the new state.
581 public void onResource(Resource resource) {
583 boolean resourceConsumed = false;
584 String incomingResourceId = resource.getId();
585 if (resourceId.equals(incomingResourceId)) {
586 if (resource.hasFullState()) {
587 thisResource = resource;
588 if (!updatePropertiesDone) {
589 updateProperties(resource);
590 resourceConsumed = updatePropertiesDone;
593 if (!updateDependenciesDone) {
594 resourceConsumed = true;
595 cancelTask(updateDependenciesTask, false);
596 updateDependenciesTask = scheduler.submit(() -> updateDependencies());
598 } else if (ResourceType.SCENE == resource.getType()) {
599 Resource cachedScene = sceneContributorsCache.get(incomingResourceId);
600 if (Objects.nonNull(cachedScene)) {
601 Setters.setResource(resource, cachedScene);
602 resourceConsumed = updateChannels(resource);
603 sceneContributorsCache.put(incomingResourceId, resource);
606 Resource cachedService = serviceContributorsCache.get(incomingResourceId);
607 if (Objects.nonNull(cachedService)) {
608 Setters.setResource(resource, cachedService);
609 resourceConsumed = updateChannels(resource);
610 serviceContributorsCache.put(incomingResourceId, resource);
611 if (ResourceType.LIGHT == resource.getType() && !updateLightPropertiesDone) {
612 updateLightProperties(resource);
616 if (resourceConsumed) {
617 logger.debug("{} -> onResource() consumed resource {}", resourceId, resource);
623 * Update the thing internal state depending on a full list of resources sent from the bridge. If the resourceType
624 * is SCENE then call updateScenes(), otherwise if the resource refers to this thing, consume it via onResource() as
625 * any other resource, or else if the resourceType nevertheless matches the thing type, set the thing state offline.
627 * @param resourceType the type of the resources in the list.
628 * @param fullResources the full list of resources of the given type.
630 public void onResourcesList(ResourceType resourceType, List<Resource> fullResources) {
631 if (resourceType == ResourceType.SCENE) {
632 updateSceneContributors(fullResources);
634 fullResources.stream().filter(r -> resourceId.equals(r.getId())).findAny()
635 .ifPresentOrElse(r -> onResource(r), () -> {
636 if (resourceType == thisResource.getType()) {
637 logger.debug("{} -> onResourcesList() configuration error: unknown resourceId", resourceId);
638 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
639 "@text/offline.api2.conf-error.resource-id-bad");
646 * Process the incoming Resource to initialize the alert channel.
648 * @param resource a Resource possibly with an Alerts element.
650 private void updateAlertChannel(Resource resource) {
651 Alerts alerts = resource.getAlerts();
652 if (Objects.nonNull(alerts)) {
653 List<StateOption> stateOptions = alerts.getActionValues().stream().map(action -> action.name())
654 .map(actionId -> new StateOption(actionId, actionId)).collect(Collectors.toList());
655 if (!stateOptions.isEmpty()) {
656 stateDescriptionProvider.setStateOptions(new ChannelUID(thing.getUID(), CHANNEL_2_ALERT), stateOptions);
657 logger.debug("{} -> updateAlerts() found {} associated alerts", resourceId, stateOptions.size());
663 * If this v2 thing has a matching v1 legacy thing in the system, then for each channel in the v1 thing that
664 * corresponds to an equivalent channel in this v2 thing, and for all items that are linked to the v1 channel,
665 * create a new channel/item link between that item and the respective v2 channel in this thing.
667 private void updateChannelItemLinksFromLegacy() {
669 legacyLinkedChannelUIDs.forEach(legacyLinkedChannelUID -> {
670 String targetChannelId = REPLICATE_CHANNEL_ID_MAP.get(legacyLinkedChannelUID.getId());
671 if (Objects.nonNull(targetChannelId)) {
672 Channel targetChannel = thing.getChannel(targetChannelId);
673 if (Objects.nonNull(targetChannel)) {
674 ChannelUID uid = targetChannel.getUID();
675 itemChannelLinkRegistry.getLinkedItems(legacyLinkedChannelUID).forEach(linkedItem -> {
676 String item = linkedItem.getName();
677 if (!itemChannelLinkRegistry.isLinked(item, uid)) {
678 if (logger.isDebugEnabled()) {
680 "{} -> updateChannelItemLinksFromLegacy() item:{} linked to channel:{}",
681 resourceId, item, uid);
683 logger.info("Item '{}' linked to thing '{}' channel '{}'", item, thing.getUID(),
686 itemChannelLinkRegistry.add(new ItemChannelLink(item, uid));
692 legacyLinkedChannelUIDs.clear();
697 * Set the active list of channels by removing any that had initially been created by the thing XML declaration, but
698 * which in fact did not have data returned from the bridge i.e. channels which are not in the supportedChannelIdSet
700 * Also warn if there are channels in the supportedChannelIdSet set which are not in the thing.
702 * Adjusts the channel list so that only the highest level channel is available in the normal channel list. If a
703 * light supports the color channel, then it's brightness and switch can be commanded via the 'B' part of the HSB
704 * channel value. And if it supports the brightness channel the switch can be controlled via the brightness. So we
705 * can remove these lower level channels from the normal channel list.
707 * For more advanced applications, it is necessary to orthogonally command the color xy parameter, dimming
708 * parameter, and/or on/off parameter independently. So we add corresponding advanced level 'CHANNEL_2_BLAH_ONLY'
709 * channels for that purpose. Since they are advanced level, normal users should normally not be confused by them,
710 * yet advanced users can use them nevertheless.
712 private void updateChannelList() {
714 synchronized (supportedChannelIdSet) {
715 logger.debug("{} -> updateChannelList()", resourceId);
717 if (supportedChannelIdSet.contains(CHANNEL_2_COLOR)) {
718 supportedChannelIdSet.add(CHANNEL_2_COLOR_XY_ONLY);
720 supportedChannelIdSet.remove(CHANNEL_2_BRIGHTNESS);
721 supportedChannelIdSet.add(CHANNEL_2_DIMMING_ONLY);
723 supportedChannelIdSet.remove(CHANNEL_2_SWITCH);
724 supportedChannelIdSet.add(CHANNEL_2_ON_OFF_ONLY);
726 if (supportedChannelIdSet.contains(CHANNEL_2_BRIGHTNESS)) {
727 supportedChannelIdSet.add(CHANNEL_2_DIMMING_ONLY);
729 supportedChannelIdSet.remove(CHANNEL_2_SWITCH);
730 supportedChannelIdSet.add(CHANNEL_2_ON_OFF_ONLY);
732 if (supportedChannelIdSet.contains(CHANNEL_2_SWITCH)) {
733 supportedChannelIdSet.add(CHANNEL_2_ON_OFF_ONLY);
737 * This binding creates its dynamic list of channels by a 'subtractive' method i.e. the full set of
738 * channels is initially created from the thing type xml, and then for any channels where UndfType.NULL
739 * data is returned, the respective channel is removed from the full list. However in seldom cases
740 * UndfType.NULL may wrongly be returned, so we should log a warning here just in case.
742 if (logger.isDebugEnabled()) {
743 supportedChannelIdSet.stream().filter(channelId -> Objects.isNull(thing.getChannel(channelId)))
744 .forEach(channelId -> logger.debug(
745 "{} -> updateChannelList() required channel '{}' missing", resourceId, channelId));
747 supportedChannelIdSet.stream().filter(channelId -> Objects.isNull(thing.getChannel(channelId)))
748 .forEach(channelId -> logger.warn(
749 "Thing '{}' is missing required channel '{}'. Please recreate the thing!",
750 thing.getUID(), channelId));
753 // get list of unused channels
754 List<Channel> unusedChannels = thing.getChannels().stream()
755 .filter(channel -> !supportedChannelIdSet.contains(channel.getUID().getId()))
756 .collect(Collectors.toList());
758 // remove any unused channels
759 if (!unusedChannels.isEmpty()) {
760 if (logger.isDebugEnabled()) {
761 unusedChannels.stream().map(channel -> channel.getUID().getId())
762 .forEach(channelId -> logger.debug(
763 "{} -> updateChannelList() removing unused channel '{}'", resourceId,
766 updateThing(editThing().withoutChannels(unusedChannels).build());
773 * Update the state of the existing channels.
775 * @param resource the Resource containing the new channel state.
776 * @return true if the channel was found and updated.
778 private boolean updateChannels(Resource resource) {
779 logger.debug("{} -> updateChannels() from resource {}", resourceId, resource);
780 boolean fullUpdate = resource.hasFullState();
781 switch (resource.getType()) {
784 addSupportedChannel(CHANNEL_2_BUTTON_LAST_EVENT);
785 controlIds.put(resource.getId(), resource.getControlId());
787 State buttonState = resource.getButtonEventState(controlIds);
788 updateState(CHANNEL_2_BUTTON_LAST_EVENT, buttonState, fullUpdate);
793 updateState(CHANNEL_2_BATTERY_LEVEL, resource.getBatteryLevelState(), fullUpdate);
794 updateState(CHANNEL_2_BATTERY_LOW, resource.getBatteryLowState(), fullUpdate);
799 updateEffectChannel(resource);
801 updateState(CHANNEL_2_COLOR_TEMP_PERCENT, resource.getColorTemperaturePercentState(), fullUpdate);
802 updateState(CHANNEL_2_COLOR_TEMP_ABSOLUTE, resource.getColorTemperatureAbsoluteState(), fullUpdate);
803 updateState(CHANNEL_2_COLOR, resource.getColorState(), fullUpdate);
804 updateState(CHANNEL_2_COLOR_XY_ONLY, resource.getColorXyState(), fullUpdate);
805 updateState(CHANNEL_2_EFFECT, resource.getEffectState(), fullUpdate);
806 // fall through for dimming and on/off related channels
810 updateAlertChannel(resource);
812 updateState(CHANNEL_2_BRIGHTNESS, resource.getBrightnessState(), fullUpdate);
813 updateState(CHANNEL_2_DIMMING_ONLY, resource.getDimmingState(), fullUpdate);
814 updateState(CHANNEL_2_SWITCH, resource.getOnOffState(), fullUpdate);
815 updateState(CHANNEL_2_ON_OFF_ONLY, resource.getOnOffState(), fullUpdate);
816 updateState(CHANNEL_2_ALERT, resource.getAlertState(), fullUpdate);
820 updateState(CHANNEL_2_LIGHT_LEVEL, resource.getLightLevelState(), fullUpdate);
821 updateState(CHANNEL_2_LIGHT_LEVEL_ENABLED, resource.getEnabledState(), fullUpdate);
825 updateState(CHANNEL_2_MOTION, resource.getMotionState(), fullUpdate);
826 updateState(CHANNEL_2_MOTION_ENABLED, resource.getEnabledState(), fullUpdate);
829 case RELATIVE_ROTARY:
831 addSupportedChannel(CHANNEL_2_ROTARY_STEPS);
833 updateState(CHANNEL_2_ROTARY_STEPS, resource.getRotaryStepsState(), fullUpdate);
838 updateState(CHANNEL_2_TEMPERATURE, resource.getTemperatureState(), fullUpdate);
839 updateState(CHANNEL_2_TEMPERATURE_ENABLED, resource.getEnabledState(), fullUpdate);
842 case ZIGBEE_CONNECTIVITY:
843 updateConnectivityState(resource);
847 updateState(CHANNEL_2_SCENE, resource.getSceneState(), fullUpdate);
853 if (thisResource.getType() == ResourceType.DEVICE) {
854 updateState(CHANNEL_2_LAST_UPDATED, new DateTimeType(), fullUpdate);
860 * Check the Zigbee connectivity and set the thing online status accordingly. If the thing is offline then set all
861 * its channel states to undefined, otherwise execute a refresh command to update channels to the latest current
864 * @param resource a Resource that potentially contains the Zigbee connectivity state.
866 private void updateConnectivityState(Resource resource) {
867 ZigbeeStatus zigbeeStatus = resource.getZigbeeStatus();
868 if (Objects.nonNull(zigbeeStatus)) {
869 logger.debug("{} -> updateConnectivityState() thingStatus:{}, zigbeeStatus:{}", resourceId,
870 thing.getStatus(), zigbeeStatus);
871 hasConnectivityIssue = zigbeeStatus != ZigbeeStatus.CONNECTED;
872 if (hasConnectivityIssue) {
873 if (thing.getStatusInfo().getStatusDetail() != ThingStatusDetail.COMMUNICATION_ERROR) {
874 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE,
875 "@text/offline.api2.comm-error.zigbee-connectivity-issue");
876 supportedChannelIdSet.forEach(channelId -> updateState(channelId, UnDefType.UNDEF));
878 } else if (thing.getStatus() != ThingStatus.ONLINE) {
879 updateStatus(ThingStatus.ONLINE);
880 // issue REFRESH command to update all channels
881 Channel lastUpdateChannel = thing.getChannel(CHANNEL_2_LAST_UPDATED);
882 if (Objects.nonNull(lastUpdateChannel)) {
883 handleCommand(lastUpdateChannel.getUID(), RefreshType.REFRESH);
890 * Get all resources needed for building the thing state. Build the forward / reverse contributor lookup maps. Set
891 * up the final list of channels in the thing.
893 private synchronized void updateDependencies() {
894 if (!disposing && !updateDependenciesDone) {
895 logger.debug("{} -> updateDependencies()", resourceId);
897 if (!updatePropertiesDone) {
898 logger.debug("{} -> updateDependencies() properties not initialized", resourceId);
901 if (!updateSceneContributorsDone && !updateSceneContributors()) {
902 logger.debug("{} -> updateDependencies() scenes not initialized", resourceId);
906 updateServiceContributors();
908 updateChannelItemLinksFromLegacy();
909 if (!hasConnectivityIssue) {
910 updateStatus(ThingStatus.ONLINE);
912 updateDependenciesDone = true;
913 } catch (ApiException e) {
914 logger.debug("{} -> updateDependencies() {}", resourceId, e.getMessage(), e);
915 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
916 } catch (AssetNotLoadedException e) {
917 logger.debug("{} -> updateDependencies() {}", resourceId, e.getMessage(), e);
918 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
919 "@text/offline.api2.conf-error.assets-not-loaded");
920 } catch (InterruptedException e) {
926 * Process the incoming Resource to initialize the effects channel.
928 * @param resource a Resource possibly with an Effects element.
930 public void updateEffectChannel(Resource resource) {
931 Effects effects = resource.getEffects();
932 if (Objects.nonNull(effects)) {
933 List<StateOption> stateOptions = effects.getStatusValues().stream()
934 .map(effect -> EffectType.of(effect).name()).map(effectId -> new StateOption(effectId, effectId))
935 .collect(Collectors.toList());
936 if (!stateOptions.isEmpty()) {
937 stateDescriptionProvider.setStateOptions(new ChannelUID(thing.getUID(), CHANNEL_2_EFFECT),
939 logger.debug("{} -> updateEffects() found {} effects", resourceId, stateOptions.size());
945 * Update the light properties.
947 * @param resource a Resource object containing the property data.
949 private synchronized void updateLightProperties(Resource resource) {
950 if (!disposing && !updateLightPropertiesDone) {
951 logger.debug("{} -> updateLightProperties()", resourceId);
953 Dimming dimming = resource.getDimming();
954 thing.setProperty(PROPERTY_DIMMING_RANGE, Objects.nonNull(dimming) ? dimming.toPropertyValue() : null);
956 MirekSchema mirekSchema = resource.getMirekSchema();
957 thing.setProperty(PROPERTY_COLOR_TEMP_RANGE,
958 Objects.nonNull(mirekSchema) ? mirekSchema.toPropertyValue() : null);
960 ColorXy colorXy = resource.getColorXy();
961 Gamut2 gamut = Objects.nonNull(colorXy) ? colorXy.getGamut2() : null;
962 thing.setProperty(PROPERTY_COLOR_GAMUT, Objects.nonNull(gamut) ? gamut.toPropertyValue() : null);
964 updateLightPropertiesDone = true;
969 * Initialize the lookup maps of resources that contribute to the thing state.
971 private void updateLookups() {
973 logger.debug("{} -> updateLookups()", resourceId);
974 // get supported services
975 List<ResourceReference> services = thisResource.getServiceReferences();
977 // add supported services to contributorsCache
978 serviceContributorsCache.clear();
979 serviceContributorsCache.putAll(services.stream()
980 .collect(Collectors.toMap(ResourceReference::getId, r -> new Resource(r.getType()))));
982 // add supported services to commandResourceIds
983 commandResourceIds.clear();
984 commandResourceIds.putAll(services.stream() // use a 'mergeFunction' to prevent duplicates
985 .collect(Collectors.toMap(ResourceReference::getType, ResourceReference::getId, (r1, r2) -> r1)));
990 * Update the primary device properties.
992 * @param resource a Resource object containing the property data.
994 private synchronized void updateProperties(Resource resource) {
995 if (!disposing && !updatePropertiesDone) {
996 logger.debug("{} -> updateProperties()", resourceId);
997 Map<String, String> properties = new HashMap<>(thing.getProperties());
1000 properties.put(PROPERTY_RESOURCE_TYPE, thisResource.getType().toString());
1001 properties.put(PROPERTY_RESOURCE_NAME, thisResource.getName());
1003 // owner information
1004 ResourceReference owner = thisResource.getOwner();
1005 if (Objects.nonNull(owner)) {
1006 String ownerId = owner.getId();
1007 if (Objects.nonNull(ownerId)) {
1008 properties.put(PROPERTY_OWNER, ownerId);
1010 ResourceType ownerType = owner.getType();
1011 properties.put(PROPERTY_OWNER_TYPE, ownerType.toString());
1015 MetaData metaData = thisResource.getMetaData();
1016 if (Objects.nonNull(metaData)) {
1017 properties.put(PROPERTY_RESOURCE_ARCHETYPE, metaData.getArchetype().toString());
1021 ProductData productData = thisResource.getProductData();
1022 if (Objects.nonNull(productData)) {
1023 // standard properties
1024 properties.put(PROPERTY_RESOURCE_ID, resourceId);
1025 properties.put(Thing.PROPERTY_MODEL_ID, productData.getModelId());
1026 properties.put(Thing.PROPERTY_VENDOR, productData.getManufacturerName());
1027 properties.put(Thing.PROPERTY_FIRMWARE_VERSION, productData.getSoftwareVersion());
1028 String hardwarePlatformType = productData.getHardwarePlatformType();
1029 if (Objects.nonNull(hardwarePlatformType)) {
1030 properties.put(Thing.PROPERTY_HARDWARE_VERSION, hardwarePlatformType);
1033 // hue specific properties
1034 properties.put(PROPERTY_PRODUCT_NAME, productData.getProductName());
1035 properties.put(PROPERTY_PRODUCT_ARCHETYPE, productData.getProductArchetype().toString());
1036 properties.put(PROPERTY_PRODUCT_CERTIFIED, productData.getCertified().toString());
1039 thing.setProperties(properties);
1040 updatePropertiesDone = true;
1045 * Execute an HTTP GET command to fetch the resources data for the referenced resource.
1047 * @param reference to the required resource.
1048 * @throws ApiException if a communication error occurred.
1049 * @throws AssetNotLoadedException if one of the assets is not loaded.
1050 * @throws InterruptedException
1052 private void updateResource(ResourceReference reference)
1053 throws ApiException, AssetNotLoadedException, InterruptedException {
1055 logger.debug("{} -> updateResource() from resource {}", resourceId, reference);
1056 getBridgeHandler().getResources(reference).getResources().stream()
1057 .forEach(resource -> onResource(resource));
1062 * Fetch the full list of scenes from the bridge, and call updateSceneContributors(List<Resource> allScenes)
1064 * @throws ApiException if a communication error occurred.
1065 * @throws AssetNotLoadedException if one of the assets is not loaded.
1066 * @throws InterruptedException
1068 public boolean updateSceneContributors() throws ApiException, AssetNotLoadedException, InterruptedException {
1069 if (!disposing && !updateSceneContributorsDone) {
1070 ResourceReference scenesReference = new ResourceReference().setType(ResourceType.SCENE);
1071 updateSceneContributors(getBridgeHandler().getResources(scenesReference).getResources());
1073 return updateSceneContributorsDone;
1077 * Process the incoming list of scene resources to find those scenes which contribute to this thing. And if there
1078 * are any, include a scene channel in the supported channel list, and populate its respective state options.
1080 * @param allScenes the full list of scene resources.
1082 public synchronized boolean updateSceneContributors(List<Resource> allScenes) {
1083 if (!disposing && !updateSceneContributorsDone) {
1084 sceneContributorsCache.clear();
1085 sceneResourceIds.clear();
1087 ResourceReference thisReference = getResourceReference();
1088 List<Resource> scenes = allScenes.stream().filter(s -> thisReference.equals(s.getGroup()))
1089 .collect(Collectors.toList());
1091 if (!scenes.isEmpty()) {
1092 sceneContributorsCache.putAll(scenes.stream().collect(Collectors.toMap(s -> s.getId(), s -> s)));
1093 sceneResourceIds.putAll(scenes.stream().collect(Collectors.toMap(s -> s.getName(), s -> s.getId())));
1095 State state = scenes.stream().filter(s -> s.getSceneActive().orElse(false)).map(s -> s.getSceneState())
1096 .findAny().orElse(UnDefType.UNDEF);
1097 updateState(CHANNEL_2_SCENE, state, true);
1099 stateDescriptionProvider.setStateOptions(new ChannelUID(thing.getUID(), CHANNEL_2_SCENE), scenes
1100 .stream().map(s -> s.getName()).map(n -> new StateOption(n, n)).collect(Collectors.toList()));
1102 logger.debug("{} -> updateSceneContributors() found {} scenes", resourceId, scenes.size());
1104 updateSceneContributorsDone = true;
1106 return updateSceneContributorsDone;
1110 * Execute a series of HTTP GET commands to fetch the resource data for all service resources that contribute to the
1113 * @throws ApiException if a communication error occurred.
1114 * @throws AssetNotLoadedException if one of the assets is not loaded.
1115 * @throws InterruptedException
1117 private void updateServiceContributors() throws ApiException, AssetNotLoadedException, InterruptedException {
1119 logger.debug("{} -> updateServiceContributors() called for {} contributors", resourceId,
1120 serviceContributorsCache.size());
1121 ResourceReference reference = new ResourceReference();
1122 for (var entry : serviceContributorsCache.entrySet()) {
1123 updateResource(reference.setId(entry.getKey()).setType(entry.getValue().getType()));
1129 * Update the channel state, and if appropriate add the channel ID to the set of supportedChannelIds. Calls either
1130 * OH core updateState() or triggerChannel() methods depending on the channel kind.
1132 * Note: the particular 'UnDefType.UNDEF' value of the state argument is used to specially indicate the undefined
1133 * state, but yet that its channel shall nevertheless continue to be present in the thing.
1135 * @param channelID the id of the channel.
1136 * @param state the new state of the channel.
1137 * @param fullUpdate if true always update the channel, otherwise only update if state is not 'UNDEF'.
1139 private void updateState(String channelID, State state, boolean fullUpdate) {
1140 boolean isDefined = state != UnDefType.NULL;
1141 Channel channel = thing.getChannel(channelID);
1143 if ((fullUpdate || isDefined) && Objects.nonNull(channel)) {
1144 logger.debug("{} -> updateState() '{}' update with '{}' (fullUpdate:{}, isDefined:{})", resourceId,
1145 channelID, state, fullUpdate, isDefined);
1147 switch (channel.getKind()) {
1149 updateState(channelID, state);
1153 if (state instanceof DecimalType) {
1154 triggerChannel(channelID, String.valueOf(((DecimalType) state).intValue()));
1158 if (fullUpdate && isDefined) {
1159 addSupportedChannel(channelID);
1164 * Check if a PROPERTY_LEGACY_THING_UID value was set by the discovery process, and if so, clone the legacy thing's
1165 * settings into this thing.
1167 private void updateThingFromLegacy() {
1168 if (isInitialized()) {
1169 logger.warn("Cannot update thing '{}' from legacy thing since handler already initialized.",
1173 Map<String, String> properties = thing.getProperties();
1174 String legacyThingUID = properties.get(PROPERTY_LEGACY_THING_UID);
1175 if (Objects.nonNull(legacyThingUID)) {
1176 Thing legacyThing = thingRegistry.get(new ThingUID(legacyThingUID));
1177 if (Objects.nonNull(legacyThing)) {
1178 ThingBuilder editBuilder = editThing();
1180 String location = legacyThing.getLocation();
1181 if (Objects.nonNull(location) && !location.isBlank()) {
1182 editBuilder = editBuilder.withLocation(location);
1185 // save list of legacyLinkedChannelUIDs for use after channel list is initialised
1186 legacyLinkedChannelUIDs.clear();
1187 legacyLinkedChannelUIDs.addAll(legacyThing.getChannels().stream().map(Channel::getUID)
1188 .filter(uid -> REPLICATE_CHANNEL_ID_MAP.containsKey(uid.getId())
1189 && itemChannelLinkRegistry.isLinked(uid))
1190 .collect(Collectors.toList()));
1192 Map<String, String> newProperties = new HashMap<>(properties);
1193 newProperties.remove(PROPERTY_LEGACY_THING_UID);
1195 updateThing(editBuilder.withProperties(newProperties).build());