From a2c36c403c79bec80541f0003dbe2c9430f32c80 Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Mon, 19 Aug 2024 20:14:29 +0100 Subject: [PATCH] [hue] Add support for enabling automations (#16980) Signed-off-by: Andrew Fiddian-Green --- .../org.openhab.binding.hue/doc/readme_v2.md | 11 + .../hue/internal/HueBindingConstants.java | 4 + .../hue/internal/api/dto/clip2/Event.java | 11 +- .../hue/internal/api/dto/clip2/MetaData.java | 6 + .../hue/internal/api/dto/clip2/Resource.java | 68 +++- .../api/dto/clip2/enums/CategoryType.java | 41 ++ .../api/dto/clip2/enums/ContentType.java | 36 ++ .../hue/internal/connection/Clip2Bridge.java | 7 +- .../internal/handler/Clip2BridgeHandler.java | 165 +++++++- .../main/resources/OH-INF/i18n/hue.properties | 10 + .../main/resources/OH-INF/thing/bridge.xml | 4 + .../main/resources/OH-INF/thing/channels.xml | 10 + .../hue/internal/clip2/Clip2DtoTest.java | 10 + .../hue/internal/clip2/SettersTest.java | 23 +- .../src/test/resources/behavior_instance.json | 368 +++++++++++++----- 15 files changed, 652 insertions(+), 122 deletions(-) create mode 100644 bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/api/dto/clip2/enums/CategoryType.java create mode 100644 bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/api/dto/clip2/enums/ContentType.java diff --git a/bundles/org.openhab.binding.hue/doc/readme_v2.md b/bundles/org.openhab.binding.hue/doc/readme_v2.md index d9b84392f0a3a..ca6b2cacb0238 100644 --- a/bundles/org.openhab.binding.hue/doc/readme_v2.md +++ b/bundles/org.openhab.binding.hue/doc/readme_v2.md @@ -55,6 +55,17 @@ See [console command](#console-command-for-finding-resourceids) The configuration of all things (as described above) is the same regardless of whether it is a device containing a light, a button, or (one or more) sensors, or whether it is a room or zone. +### Channels for Bridges + +Bridge Things support the following channels: + +| Channel ID | Item Type | Description | +|-------------------------------------------------|--------------------|---------------------------------------------| +| automation#11111111-2222-3333-4444-555555555555 | Switch | Enable / disable the respective automation. | + +The Bridge dynamically creates `automation` channels corresponding to the automations in the Hue App; +the '11111111-2222-3333-4444-555555555555' is the unique id of the respective automation. + ### Channels for Devices Device things support some of the following channels: diff --git a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueBindingConstants.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueBindingConstants.java index 8ed1c5ab4213c..8b338dbe37631 100644 --- a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueBindingConstants.java +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueBindingConstants.java @@ -17,6 +17,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.type.ChannelTypeUID; /** * The {@link HueBindingConstants} class defines common constants, which are @@ -200,4 +201,7 @@ public class HueBindingConstants { Map.entry(CHANNEL_LAST_UPDATED, CHANNEL_2_LAST_UPDATED)); public static final String ALL_LIGHTS_KEY = "discovery.group.all-lights.label"; + + public static final String CHANNEL_GROUP_AUTOMATION = "automation"; + public static final ChannelTypeUID CHANNEL_TYPE_AUTOMATION = new ChannelTypeUID(BINDING_ID, "automation-enable"); } diff --git a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/api/dto/clip2/Event.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/api/dto/clip2/Event.java index 7a27ff721fa03..9712bb593c758 100644 --- a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/api/dto/clip2/Event.java +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/api/dto/clip2/Event.java @@ -13,13 +13,14 @@ package org.openhab.binding.hue.internal.api.dto.clip2; import java.lang.reflect.Type; -import java.util.ArrayList; import java.util.List; import java.util.Objects; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.hue.internal.api.dto.clip2.enums.ContentType; +import com.google.gson.annotations.SerializedName; import com.google.gson.reflect.TypeToken; /** @@ -32,7 +33,13 @@ public class Event { public static final Type EVENT_LIST_TYPE = new TypeToken>() { }.getType(); - private @Nullable List data = new ArrayList<>(); + private @Nullable List data; + private @Nullable @SerializedName("type") ContentType contentType; // content type of resources + + public ContentType getContentType() { + ContentType contentType = this.contentType; + return Objects.nonNull(contentType) ? contentType : ContentType.ERROR; + } public List getData() { List data = this.data; diff --git a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/api/dto/clip2/MetaData.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/api/dto/clip2/MetaData.java index 7194a582229c2..3a44de9e5ee93 100644 --- a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/api/dto/clip2/MetaData.java +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/api/dto/clip2/MetaData.java @@ -15,6 +15,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.hue.internal.api.dto.clip2.enums.Archetype; +import org.openhab.binding.hue.internal.api.dto.clip2.enums.CategoryType; import com.google.gson.annotations.SerializedName; @@ -28,6 +29,7 @@ public class MetaData { private @Nullable String archetype; private @Nullable String name; private @Nullable @SerializedName("control_id") Integer controlId; + private @Nullable String category; public Archetype getArchetype() { return Archetype.of(archetype); @@ -37,6 +39,10 @@ public Archetype getArchetype() { return name; } + public CategoryType getCategory() { + return CategoryType.of(category); + } + public int getControlId() { Integer controlId = this.controlId; return controlId != null ? controlId.intValue() : 0; diff --git a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/api/dto/clip2/Resource.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/api/dto/clip2/Resource.java index 6cae9c1e5904b..8adb111a2d4b0 100644 --- a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/api/dto/clip2/Resource.java +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/api/dto/clip2/Resource.java @@ -28,7 +28,9 @@ import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.hue.internal.api.dto.clip2.enums.ActionType; import org.openhab.binding.hue.internal.api.dto.clip2.enums.ButtonEventType; +import org.openhab.binding.hue.internal.api.dto.clip2.enums.CategoryType; import org.openhab.binding.hue.internal.api.dto.clip2.enums.ContactStateType; +import org.openhab.binding.hue.internal.api.dto.clip2.enums.ContentType; import org.openhab.binding.hue.internal.api.dto.clip2.enums.EffectType; import org.openhab.binding.hue.internal.api.dto.clip2.enums.ResourceType; import org.openhab.binding.hue.internal.api.dto.clip2.enums.SceneRecallAction; @@ -55,6 +57,7 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; import com.google.gson.annotations.SerializedName; /** @@ -74,8 +77,16 @@ public class Resource { * values have changed. A sparse resource does not contain the full state of the resource. And the absence of any * field from such a resource does not indicate that the field value is UNDEF, but rather that the value is the same * as what it was previously set to by the last non-sparse resource. + *

+ * The following content types are defined: + * + *

  • ADD resource being added; contains (assumed) all fields
  • + *
  • DELETE resource being deleted; contains id and type only
  • + *
  • UPDATE resource being updated; contains id, type and changed fields
  • + *
  • ERROR resource with error; contents unknown
  • + *
  • FULL_STATE existing resource being downloaded; contains all fields
  • */ - private transient boolean hasSparseData; + private transient ContentType contentType; private @Nullable String type; private @Nullable String id; @@ -107,7 +118,15 @@ public class Resource { private @Nullable Dynamics dynamics; private @Nullable @SerializedName("contact_report") ContactReport contactReport; private @Nullable @SerializedName("tamper_reports") List tamperReports; - private @Nullable String state; + private @Nullable JsonElement state; + private @Nullable @SerializedName("script_id") String scriptId; + + /** + * Constructor + */ + public Resource() { + contentType = ContentType.FULL_STATE; + } /** * Constructor @@ -115,6 +134,7 @@ public class Resource { * @param resourceType */ public Resource(@Nullable ResourceType resourceType) { + this(); if (Objects.nonNull(resourceType)) { setType(resourceType); } @@ -343,6 +363,14 @@ public State getColorTemperaturePercentState() { return color; } + /** + * Return the resource's metadata category. + */ + public CategoryType getCategory() { + MetaData metaData = getMetaData(); + return Objects.nonNull(metaData) ? metaData.getCategory() : CategoryType.NULL; + } + /** * Return an HSB where the HS part is derived from the color xy JSON element (only), so the B part is 100% * @@ -375,6 +403,10 @@ public State getContactState() { : OpenClosedType.OPEN; } + public ContentType getContentType() { + return contentType; + } + public int getControlId() { MetaData metadata = this.metadata; return Objects.nonNull(metadata) ? metadata.getControlId() : 0; @@ -648,6 +680,13 @@ public Optional getSceneActive() { return Optional.empty(); } + /** + * Return the scriptId if any. + */ + public @Nullable String getScriptId() { + return scriptId; + } + /** * If the getSceneActive() optional result is empty return 'UnDefType.NULL'. Otherwise if the optional result is * present and 'true' (i.e. the scene is active) return the scene name. Or finally (the optional result is present @@ -661,13 +700,14 @@ public State getSceneState() { /** * Check if the smart scene resource contains a 'state' element. If such an element is present, returns a Boolean - * Optional whose value depends on the value of that element, or an empty Optional if it is not. + * Optional whose value depends on the value of that element, or an empty Optional if it is not. Note that in some + * resource types the 'state' element is not a String primitive. * * @return true, false, or empty. */ public Optional getSmartSceneActive() { - if (ResourceType.SMART_SCENE == getType()) { - String state = this.state; + if (ResourceType.SMART_SCENE == getType() && (state instanceof JsonPrimitive statePrimitive)) { + String state = statePrimitive.getAsString(); if (Objects.nonNull(state)) { return Optional.of(SmartSceneState.ACTIVE == SmartSceneState.of(state)); } @@ -785,17 +825,12 @@ public State getZigbeeState() { } public boolean hasFullState() { - return !hasSparseData; + return ContentType.FULL_STATE == contentType; } - /** - * Mark that the resource has sparse data. - * - * @return this instance. - */ - public Resource markAsSparse() { - hasSparseData = true; - return this; + public boolean hasName() { + MetaData metaData = getMetaData(); + return Objects.nonNull(metaData) && Objects.nonNull(metaData.getName()); } public Resource setAlerts(Alerts alert) { @@ -818,6 +853,11 @@ public Resource setContactReport(ContactReport contactReport) { return this; } + public Resource setContentType(ContentType contentType) { + this.contentType = contentType; + return this; + } + public Resource setDimming(@Nullable Dimming dimming) { this.dimming = dimming; return this; diff --git a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/api/dto/clip2/enums/CategoryType.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/api/dto/clip2/enums/CategoryType.java new file mode 100644 index 0000000000000..6b27bf6dd4c22 --- /dev/null +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/api/dto/clip2/enums/CategoryType.java @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.hue.internal.api.dto.clip2.enums; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Enum for 'category' fields. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public enum CategoryType { + ACCESSORY, + AUTOMATION, + ENTERTAINMENT, + NULL, + UNDEF; + + public static CategoryType of(@Nullable String value) { + if (value != null) { + try { + return valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + return UNDEF; + } + } + return NULL; + } +} diff --git a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/api/dto/clip2/enums/ContentType.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/api/dto/clip2/enums/ContentType.java new file mode 100644 index 0000000000000..4e3901f0b3822 --- /dev/null +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/api/dto/clip2/enums/ContentType.java @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.hue.internal.api.dto.clip2.enums; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +import com.google.gson.annotations.SerializedName; + +/** + * Enum for content type of Resource instances + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public enum ContentType { + @SerializedName("add") // resource being added; contains (maybe) all fields + ADD, + @SerializedName("delete") // resource being deleted; contains id and type only + DELETE, + @SerializedName("update") // resource being updated; contains id, type and updated fields + UPDATE, + @SerializedName("error") // resource error event + ERROR, + // existing resource being downloaded; contains all fields; excluded from (de-)serialization + FULL_STATE +} diff --git a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/connection/Clip2Bridge.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/connection/Clip2Bridge.java index 69d7fa67caa21..13230ac9510f6 100644 --- a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/connection/Clip2Bridge.java +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/connection/Clip2Bridge.java @@ -921,12 +921,15 @@ protected void onEventData(String data) { return; } List resources = new ArrayList<>(); - events.forEach(event -> resources.addAll(event.getData())); + events.forEach(event -> { + List eventResources = event.getData(); + eventResources.forEach(resource -> resource.setContentType(event.getContentType())); + resources.addAll(eventResources); + }); if (resources.isEmpty()) { LOGGER.debug("onEventData() resource list is empty"); return; } - resources.forEach(resource -> resource.markAsSparse()); bridgeHandler.onResourcesEvent(resources); } diff --git a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/Clip2BridgeHandler.java b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/Clip2BridgeHandler.java index 3fdeeab9feb50..37f7c6e8f7981 100644 --- a/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/Clip2BridgeHandler.java +++ b/bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/Clip2BridgeHandler.java @@ -27,6 +27,8 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.stream.Stream; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; @@ -36,6 +38,7 @@ import org.openhab.binding.hue.internal.api.dto.clip2.ResourceReference; import org.openhab.binding.hue.internal.api.dto.clip2.Resources; import org.openhab.binding.hue.internal.api.dto.clip2.enums.Archetype; +import org.openhab.binding.hue.internal.api.dto.clip2.enums.CategoryType; import org.openhab.binding.hue.internal.api.dto.clip2.enums.ResourceType; import org.openhab.binding.hue.internal.api.dto.clip2.helper.Setters; import org.openhab.binding.hue.internal.config.Clip2BridgeConfig; @@ -50,7 +53,10 @@ import org.openhab.core.i18n.TranslationProvider; import org.openhab.core.io.net.http.HttpClientFactory; import org.openhab.core.io.net.http.TlsTrustManagerProvider; +import org.openhab.core.library.CoreItemFactory; import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.Channel; +import org.openhab.core.thing.ChannelGroupUID; import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingRegistry; @@ -62,6 +68,7 @@ import org.openhab.core.thing.binding.ThingHandler; import org.openhab.core.thing.binding.ThingHandlerService; import org.openhab.core.thing.binding.builder.BridgeBuilder; +import org.openhab.core.thing.binding.builder.ChannelBuilder; import org.openhab.core.types.Command; import org.openhab.core.types.RefreshType; import org.osgi.framework.Bundle; @@ -93,6 +100,11 @@ public class Clip2BridgeHandler extends BaseBridgeHandler { private static final ResourceReference BRIDGE_HOME = new ResourceReference().setType(ResourceType.BRIDGE_HOME); private static final ResourceReference SCENE = new ResourceReference().setType(ResourceType.SCENE); private static final ResourceReference SMART_SCENE = new ResourceReference().setType(ResourceType.SMART_SCENE); + private static final ResourceReference SCRIPT = new ResourceReference().setType(ResourceType.BEHAVIOR_SCRIPT); + private static final ResourceReference BEHAVIOR = new ResourceReference().setType(ResourceType.BEHAVIOR_INSTANCE); + + private static final String AUTOMATION_CHANNEL_LABEL_KEY = "dynamic-channel.automation-enable.label"; + private static final String AUTOMATION_CHANNEL_DESCRIPTION_KEY = "dynamic-channel.automation-enable.description"; /** * List of resource references that need to be mass down loaded. @@ -107,11 +119,15 @@ public class Clip2BridgeHandler extends BaseBridgeHandler { private final Bundle bundle; private final LocaleProvider localeProvider; private final TranslationProvider translationProvider; + private final Map automationsCache = new ConcurrentHashMap<>();; + private final Set automationScriptIds = ConcurrentHashMap.newKeySet(); + private final ChannelGroupUID automationChannelGroupUID; private @Nullable Clip2Bridge clip2Bridge; private @Nullable ServiceRegistration trustManagerRegistration; private @Nullable Clip2ThingDiscoveryService discoveryService; + private @Nullable Future updateAutomationChannelsTask; private @Nullable Future checkConnectionTask; private @Nullable Future updateOnlineStateTask; private @Nullable ScheduledFuture scheduledUpdateTask; @@ -129,6 +145,7 @@ public Clip2BridgeHandler(Bridge bridge, HttpClientFactory httpClientFactory, Th this.bundle = FrameworkUtil.getBundle(getClass()); this.localeProvider = localeProvider; this.translationProvider = translationProvider; + this.automationChannelGroupUID = new ChannelGroupUID(thing.getUID(), CHANNEL_GROUP_AUTOMATION); } /** @@ -265,9 +282,11 @@ private void disposeAssets() { logger.debug("disposeAssets() {}", this); synchronized (this) { assetsLoaded = false; + cancelTask(updateAutomationChannelsTask, true); cancelTask(checkConnectionTask, true); cancelTask(updateOnlineStateTask, true); cancelTask(scheduledUpdateTask, true); + updateAutomationChannelsTask = null; checkConnectionTask = null; updateOnlineStateTask = null; scheduledUpdateTask = null; @@ -418,10 +437,25 @@ public Collection> getServices() { @Override public void handleCommand(ChannelUID channelUID, Command command) { - if (RefreshType.REFRESH.equals(command)) { - return; + if (CHANNEL_GROUP_AUTOMATION.equals(channelUID.getGroupId())) { + try { + if (RefreshType.REFRESH.equals(command)) { + updateAutomationChannelsNow(); + return; + } else { + Resources resources = getClip2Bridge().putResource(new Resource(ResourceType.BEHAVIOR_INSTANCE) + .setId(channelUID.getIdWithoutGroup()).setEnabled(command)); + if (resources.hasErrors()) { + logger.warn("handleCommand({}, {}) succeeded with errors: {}", channelUID, command, + String.join("; ", resources.getErrors())); + } + } + } catch (ApiException | AssetNotLoadedException e) { + logger.warn("handleCommand({}, {}) error {}", channelUID, command, e.getMessage(), + logger.isDebugEnabled() ? e : null); + } catch (InterruptedException e) { + } } - logger.warn("Bridge thing '{}' has no channels, only REFRESH command supported.", thing.getUID()); } @Override @@ -533,6 +567,9 @@ private void onResourcesEventTask(List resources) { if (numberOfResources != resources.size()) { logger.debug("onResourcesEventTask() merged to {} resources", resources.size()); } + if (onResources(resources)) { + updateAutomationChannelsNow(); + } getThing().getThings().forEach(thing -> { if (thing.getHandler() instanceof Clip2ThingHandler clip2ThingHandler) { clip2ThingHandler.onResources(resources); @@ -598,6 +635,8 @@ private void updateOnlineState() { logger.debug("updateOnlineState()"); connectRetriesRemaining = RECONNECT_MAX_TRIES; updateStatus(ThingStatus.ONLINE); + loadAutomationScriptIds(); + updateAutomationChannelsNow(); updateThingsScheduled(500); Clip2ThingDiscoveryService discoveryService = this.discoveryService; if (Objects.nonNull(discoveryService)) { @@ -775,4 +814,124 @@ private void updateThingsScheduled(int delayMilliSeconds) { scheduledUpdateTask = scheduler.schedule(() -> updateThingsNow(), delayMilliSeconds, TimeUnit.MILLISECONDS); } } + + /** + * Load the set of automation script ids. + */ + private void loadAutomationScriptIds() { + try { + synchronized (automationScriptIds) { + automationScriptIds.clear(); + automationScriptIds.addAll(getClip2Bridge().getResources(SCRIPT).getResources().stream() + .filter(r -> CategoryType.AUTOMATION == r.getCategory()).map(r -> r.getId()) + .collect(Collectors.toSet())); + } + } catch (ApiException | AssetNotLoadedException e) { + logger.warn("loadAutomationScriptIds() unexpected exception {}", e.getMessage(), + logger.isDebugEnabled() ? e : null); + } catch (InterruptedException e) { + } + } + + /** + * Create resp. update the automation channels + */ + private void updateAutomationChannels() { + List automations; + try { + automations = getClip2Bridge().getResources(BEHAVIOR).getResources().stream() + .filter(r -> automationScriptIds.contains(r.getScriptId())).toList(); + } catch (ApiException | AssetNotLoadedException e) { + logger.warn("Unexpected exception '{}' while updating channels.", e.getMessage(), + logger.isDebugEnabled() ? e : null); + return; + } catch (InterruptedException e) { + return; + } + + if (automations.size() != automationsCache.size() || automations.stream().anyMatch(automation -> { + Resource cachedAutomation = automationsCache.get(automation.getId()); + return Objects.isNull(cachedAutomation) || !automation.getName().equals(cachedAutomation.getName()); + })) { + + synchronized (automationsCache) { + automationsCache.clear(); + automationsCache.putAll(automations.stream().collect(Collectors.toMap(a -> a.getId(), a -> a))); + } + + Stream newChannels = automations.stream().map(a -> createAutomationChannel(a)); + Stream oldchannels = thing.getChannels().stream() + .filter(c -> !CHANNEL_TYPE_AUTOMATION.equals(c.getChannelTypeUID())); + + updateThing(editThing().withChannels(Stream.concat(oldchannels, newChannels).toList()).build()); + onResources(automations); + + logger.debug("Bridge created {} automation channels", automations.size()); + } + } + + /** + * Start a task to update the automation channels + */ + private void updateAutomationChannelsNow() { + cancelTask(updateAutomationChannelsTask, false); + updateAutomationChannelsTask = scheduler.submit(() -> updateAutomationChannels()); + } + + /** + * Create an automation channel from an automation resource + */ + private Channel createAutomationChannel(Resource automation) { + String label = Objects.requireNonNullElse(translationProvider.getText(bundle, AUTOMATION_CHANNEL_LABEL_KEY, + AUTOMATION_CHANNEL_LABEL_KEY, localeProvider.getLocale(), automation.getName()), + AUTOMATION_CHANNEL_LABEL_KEY); + + String description = Objects.requireNonNullElse( + translationProvider.getText(bundle, AUTOMATION_CHANNEL_DESCRIPTION_KEY, + AUTOMATION_CHANNEL_DESCRIPTION_KEY, localeProvider.getLocale(), automation.getName()), + AUTOMATION_CHANNEL_DESCRIPTION_KEY); + + return ChannelBuilder + .create(new ChannelUID(automationChannelGroupUID, automation.getId()), CoreItemFactory.SWITCH) + .withLabel(label).withDescription(description).withType(CHANNEL_TYPE_AUTOMATION).build(); + } + + /** + * Process event resources list + * + * @return true if the automation channels require updating + */ + public boolean onResources(List resources) { + boolean requireUpdateChannels = false; + for (Resource resource : resources) { + if (ResourceType.BEHAVIOR_INSTANCE != resource.getType()) { + continue; + } + String resourceId = resource.getId(); + switch (resource.getContentType()) { + case ADD: + requireUpdateChannels |= automationScriptIds.contains(resource.getScriptId()); + break; + case DELETE: + requireUpdateChannels |= automationsCache.containsKey(resourceId); + break; + case UPDATE: + case FULL_STATE: + Resource cachedAutomation = automationsCache.get(resourceId); + if (Objects.isNull(cachedAutomation)) { + requireUpdateChannels |= automationScriptIds.contains(resource.getScriptId()); + } else { + if (resource.hasName() && !resource.getName().equals(cachedAutomation.getName())) { + requireUpdateChannels = true; + } else if (Objects.nonNull(resource.getEnabled())) { + updateState(new ChannelUID(automationChannelGroupUID, resourceId), + resource.getEnabledState()); + } + } + break; + default: + } + } + return requireUpdateChannels; + } } diff --git a/bundles/org.openhab.binding.hue/src/main/resources/OH-INF/i18n/hue.properties b/bundles/org.openhab.binding.hue/src/main/resources/OH-INF/i18n/hue.properties index 48219ed5cca42..4e3c8336f1f45 100644 --- a/bundles/org.openhab.binding.hue/src/main/resources/OH-INF/i18n/hue.properties +++ b/bundles/org.openhab.binding.hue/src/main/resources/OH-INF/i18n/hue.properties @@ -130,6 +130,10 @@ thing-type.config.hue.room.resourceId.description = Unique Resource ID of the ro thing-type.config.hue.zone.resourceId.label = Resource ID thing-type.config.hue.zone.resourceId.description = Unique Resource ID of the zone in the Hue bridge +# channel group types + +channel-group-type.hue.automation.label = Automations + # channel types channel-type.hue.advanced-brightness.label = Dimming Only @@ -144,6 +148,7 @@ channel-type.hue.alert.description = The alert channel allows a temporary change channel-type.hue.alert.state.option.NONE = None channel-type.hue.alert.state.option.SELECT = Alert channel-type.hue.alert.state.option.LSELECT = Long Alert +channel-type.hue.automation-enable.label = Enable channel-type.hue.button-last-event.label = Button Last Event channel-type.hue.button-last-event.description = Numeric code (e.g. 1003) representing the last push button event. channel-type.hue.dark.label = Dark @@ -292,3 +297,8 @@ dynamics.command.label = Target Command dynamics.command.description = The target command state for the light(s) to transition to. dynamics.duration.label = Duration dynamics.duration.description = The dynamic transition duration in ms. + +# dynamic channels + +dynamic-channel.automation-enable.label = Enable ''{0}'' +dynamic-channel.automation-enable.description = Enable the ''{0}'' automation diff --git a/bundles/org.openhab.binding.hue/src/main/resources/OH-INF/thing/bridge.xml b/bundles/org.openhab.binding.hue/src/main/resources/OH-INF/thing/bridge.xml index d2ea0f4bee6ee..f763a8d23cd0a 100644 --- a/bundles/org.openhab.binding.hue/src/main/resources/OH-INF/thing/bridge.xml +++ b/bundles/org.openhab.binding.hue/src/main/resources/OH-INF/thing/bridge.xml @@ -67,6 +67,10 @@ The Hue Bridge represents a Philips Hue Bridge supporting API v2. + + + + serialNumber diff --git a/bundles/org.openhab.binding.hue/src/main/resources/OH-INF/thing/channels.xml b/bundles/org.openhab.binding.hue/src/main/resources/OH-INF/thing/channels.xml index b25510071b6ba..68c79045c885a 100644 --- a/bundles/org.openhab.binding.hue/src/main/resources/OH-INF/thing/channels.xml +++ b/bundles/org.openhab.binding.hue/src/main/resources/OH-INF/thing/channels.xml @@ -286,4 +286,14 @@ Siren + + Switch + + Switch + + + + + + diff --git a/bundles/org.openhab.binding.hue/src/test/java/org/openhab/binding/hue/internal/clip2/Clip2DtoTest.java b/bundles/org.openhab.binding.hue/src/test/java/org/openhab/binding/hue/internal/clip2/Clip2DtoTest.java index 229d3bb9afa37..fc7be62844895 100644 --- a/bundles/org.openhab.binding.hue/src/test/java/org/openhab/binding/hue/internal/clip2/Clip2DtoTest.java +++ b/bundles/org.openhab.binding.hue/src/test/java/org/openhab/binding/hue/internal/clip2/Clip2DtoTest.java @@ -905,4 +905,14 @@ void testTimedEffectSetter() { assertTrue(resultEffect instanceof TimedEffects); assertEquals(Duration.ofMillis(44), ((TimedEffects) resultEffect).getDuration()); } + + @Test + void testBehaviorInstance() { + String json = load(ResourceType.BEHAVIOR_INSTANCE.name().toLowerCase()); + Resources resources = GSON.fromJson(json, Resources.class); + assertNotNull(resources); + List list = resources.getResources(); + assertNotNull(list); + assertEquals(2, list.size()); + } } diff --git a/bundles/org.openhab.binding.hue/src/test/java/org/openhab/binding/hue/internal/clip2/SettersTest.java b/bundles/org.openhab.binding.hue/src/test/java/org/openhab/binding/hue/internal/clip2/SettersTest.java index d005cb906fbe6..2f5efa7a15c45 100644 --- a/bundles/org.openhab.binding.hue/src/test/java/org/openhab/binding/hue/internal/clip2/SettersTest.java +++ b/bundles/org.openhab.binding.hue/src/test/java/org/openhab/binding/hue/internal/clip2/SettersTest.java @@ -30,13 +30,14 @@ import org.openhab.binding.hue.internal.api.dto.clip2.Effects; import org.openhab.binding.hue.internal.api.dto.clip2.OnState; import org.openhab.binding.hue.internal.api.dto.clip2.Resource; +import org.openhab.binding.hue.internal.api.dto.clip2.enums.ContentType; import org.openhab.binding.hue.internal.api.dto.clip2.enums.ResourceType; import org.openhab.binding.hue.internal.api.dto.clip2.helper.Setters; import org.openhab.binding.hue.internal.exceptions.DTOPresentButEmptyException; /** * Tests for {@link Setters}. - * + * * @author Jacob Laursen - Initial contribution */ @NonNullByDefault @@ -51,7 +52,7 @@ public class SettersTest { * * Expected output: * - Resource 1: type=light/grouped_light, sparse, id=1, on=on, dimming=50 - * + * * @throws DTOPresentButEmptyException */ @ParameterizedTest @@ -100,7 +101,7 @@ private static Stream provideLightResourceTypes() { * * Expected output: * - Resource 1: type=light, sparse, id=1, dimming=50 - * + * * @throws DTOPresentButEmptyException */ @Test @@ -137,7 +138,7 @@ void mergeLightResourcesMergeDimmingToLatestValueWhenSparseAndSameId() throws DT * Expected output: * - Resource 1: type=light, sparse, id=1, on=on, dimming=50 * - Resource 2: type=light, sparse, id=1, effect=xxx - * + * * @throws DTOPresentButEmptyException */ @Test @@ -185,7 +186,7 @@ void mergeLightResourcesMergeHSBFieldsDoNotRemoveResourceWithEffect() throws DTO * Expected output: * - Resource 1: type=light, sparse, id=1, on=on * - Resource 2: type=light, sparse, id=2, dimming=50 - * + * * @throws DTOPresentButEmptyException */ @Test @@ -228,7 +229,7 @@ void mergeLightResourcesDoNotMergeOnStateAndDimmingWhenSparseAndDifferentId() th * * Expected output: * - Exception thrown, full state is not supported/expected. - * + * * @throws DTOPresentButEmptyException */ @Test @@ -254,7 +255,7 @@ void mergeLightResourcesMergeOnStateAndDimmingWhenFullStateFirstAndSameId() thro * Expected output: * - Resource 1: type=light, sparse, id=1, on=on * - Resource 2: type=light, sparse, id=1, color temperature=370 mirek - * + * * @throws DTOPresentButEmptyException */ @Test @@ -301,7 +302,7 @@ void mergeLightResourcesDoNotMergeOnStateAndColorTemperatureWhenSparseAndSameId( * Expected output: * - Resource 1: type=light, sparse, id=1, on=on, dimming=50 * - Resource 2: type=light, sparse, id=1, color temperature=370 mirek - * + * * @throws DTOPresentButEmptyException */ @Test @@ -352,7 +353,7 @@ void mergeLightResourcesMergeOnStateAndDimmingButNotColorTemperatureWhenSparseAn * * Expected output: * - Resource 1: type=light, sparse, id=1, on=on, color temperature=370 - * + * * @throws DTOPresentButEmptyException */ @Test @@ -389,7 +390,7 @@ void mergeLightResourcesSeparateOnStateAndColorTemperatureWhenSparseAndSameId() * * Expected output: * - Resource 1: type=motion, sparse, id=1 - * + * * @throws DTOPresentButEmptyException */ @Test @@ -431,7 +432,7 @@ private ColorTemperature createColorTemperature(double mirek) { private Resource createResource(ResourceType resourceType, String id) { Resource resource = new Resource(resourceType); resource.setId(id); - resource.markAsSparse(); + resource.setContentType(ContentType.UPDATE); return resource; } diff --git a/bundles/org.openhab.binding.hue/src/test/resources/behavior_instance.json b/bundles/org.openhab.binding.hue/src/test/resources/behavior_instance.json index 2c2c4f4aa5364..3da61f4dea4e8 100644 --- a/bundles/org.openhab.binding.hue/src/test/resources/behavior_instance.json +++ b/bundles/org.openhab.binding.hue/src/test/resources/behavior_instance.json @@ -1,91 +1,279 @@ { - "errors": [], - "data": [ - { - "configuration": { - "what": [ - { - "group": { - "rid": "b8d28681-eba1-4156-85e2-96c9c5179fba", - "rtype": "room" - }, - "recall": { - "rid": "11ac9c82-d031-43a6-a8d5-b6efdee72fe6", - "rtype": "scene" - } - }, - { - "group": { - "rid": "8b529073-36dd-409b-8006-80df304048ea", - "rtype": "room" - }, - "recall": { - "rid": "8b65c749-3ad8-435e-a7ed-94e3cc99e9d7", - "rtype": "scene" - } - } - ], - "when_constrained": { - "type": "nighttime" - }, - "where": [ - { - "group": { - "rid": "b8d28681-eba1-4156-85e2-96c9c5179fba", - "rtype": "room" - } - }, - { - "group": { - "rid": "8b529073-36dd-409b-8006-80df304048ea", - "rtype": "room" - } - } - ] - }, - "dependees": [ - { - "level": "critical", - "target": { - "rid": "b8d28681-eba1-4156-85e2-96c9c5179fba", - "rtype": "room" - }, - "type": "ResourceDependee" - }, - { - "level": "critical", - "target": { - "rid": "11ac9c82-d031-43a6-a8d5-b6efdee72fe6", - "rtype": "scene" - }, - "type": "ResourceDependee" - }, - { - "level": "critical", - "target": { - "rid": "8b529073-36dd-409b-8006-80df304048ea", - "rtype": "room" - }, - "type": "ResourceDependee" - }, - { - "level": "critical", - "target": { - "rid": "8b65c749-3ad8-435e-a7ed-94e3cc99e9d7", - "rtype": "scene" - }, - "type": "ResourceDependee" - } - ], - "enabled": true, - "id": "8d0ffbee-e24e-4d3e-b91a-5adc9ef5d49c", - "last_error": "", - "metadata": { - "name": "Coming home" - }, - "script_id": "fd60fcd1-4809-4813-b510-4a18856a595c", - "status": "running", - "type": "behavior_instance" - } - ] -} \ No newline at end of file + "errors": [ + ], + "data": [ + { + "id": "042284f9-eeae-4f1e-9560-cc73750c7d28", + "type": "behavior_instance", + "script_id": "67d9395b-4403-42cc-b5f0-740b699d67c6", + "enabled": true, + "state": { + "model_id": "RWL021", + "source_type": "device" + }, + "configuration": { + "buttons": { + "6615f1f1-f3f1-4a05-b8f7-581097458e34": { + "on_repeat": { + "action": "dim_down" + } + }, + "91ba8839-2bac-4175-9f8c-ed192842d549": { + "on_long_press": { + "action": "do_nothing" + }, + "on_short_release": { + "time_based_extended": { + "slots": [ + { + "actions": [ + { + "action": { + "recall": { + "rid": "f021deb5-5104-4752-aab3-2849f84da690", + "rtype": "scene" + } + } + } + ], + "start_time": { + "hour": 7, + "minute": 0 + } + }, + { + "actions": [ + { + "action": { + "recall": { + "rid": "4ddd4f8b-428c-4089-a9a1-c27df5259b9a", + "rtype": "scene" + } + } + } + ], + "start_time": { + "hour": 20, + "minute": 0 + } + }, + { + "actions": [ + { + "action": { + "recall": { + "rid": "af0c88c4-9dae-4767-8475-a3cca906390d", + "rtype": "scene" + } + } + } + ], + "start_time": { + "hour": 23, + "minute": 0 + } + } + ], + "with_off": { + "enabled": false + } + } + } + }, + "b0d5a0af-31fd-4189-9150-c551ff9033d7": { + "on_long_press": { + "action": "do_nothing" + }, + "on_short_release": { + "action": "all_off" + } + }, + "f95addfc-2f7c-453f-924d-ba496e07e5f9": { + "on_repeat": { + "action": "dim_up" + } + } + }, + "device": { + "rid": "e130feac-3a5c-452e-a97d-5bca470783b3", + "rtype": "device" + }, + "model_id": "RWL021", + "where": [ + { + "group": { + "rid": "bdc282b3-750d-45dd-b6c4-12a2927d8951", + "rtype": "zone" + } + } + ] + }, + "dependees": [ + { + "target": { + "rid": "e130feac-3a5c-452e-a97d-5bca470783b3", + "rtype": "device" + }, + "level": "critical", + "type": "ResourceDependee" + }, + { + "target": { + "rid": "bdc282b3-750d-45dd-b6c4-12a2927d8951", + "rtype": "zone" + }, + "level": "critical", + "type": "ResourceDependee" + }, + { + "target": { + "rid": "f021deb5-5104-4752-aab3-2849f84da690", + "rtype": "scene" + }, + "level": "critical", + "type": "ResourceDependee" + }, + { + "target": { + "rid": "4ddd4f8b-428c-4089-a9a1-c27df5259b9a", + "rtype": "scene" + }, + "level": "critical", + "type": "ResourceDependee" + }, + { + "target": { + "rid": "af0c88c4-9dae-4767-8475-a3cca906390d", + "rtype": "scene" + }, + "level": "critical", + "type": "ResourceDependee" + }, + { + "target": { + "rid": "91ba8839-2bac-4175-9f8c-ed192842d549", + "rtype": "button" + }, + "level": "critical", + "type": "ResourceDependee" + }, + { + "target": { + "rid": "f95addfc-2f7c-453f-924d-ba496e07e5f9", + "rtype": "button" + }, + "level": "critical", + "type": "ResourceDependee" + }, + { + "target": { + "rid": "6615f1f1-f3f1-4a05-b8f7-581097458e34", + "rtype": "button" + }, + "level": "critical", + "type": "ResourceDependee" + }, + { + "target": { + "rid": "b0d5a0af-31fd-4189-9150-c551ff9033d7", + "rtype": "button" + }, + "level": "critical", + "type": "ResourceDependee" + } + ], + "status": "running", + "last_error": "", + "metadata": { + "name": "Worktops Dimmer Pad Right" + }, + "migrated_from": "/resourcelinks/5338" + }, + { + "configuration": { + "what": [ + { + "group": { + "rid": "b8d28681-eba1-4156-85e2-96c9c5179fba", + "rtype": "room" + }, + "recall": { + "rid": "11ac9c82-d031-43a6-a8d5-b6efdee72fe6", + "rtype": "scene" + } + }, + { + "group": { + "rid": "8b529073-36dd-409b-8006-80df304048ea", + "rtype": "room" + }, + "recall": { + "rid": "8b65c749-3ad8-435e-a7ed-94e3cc99e9d7", + "rtype": "scene" + } + } + ], + "when_constrained": { + "type": "nighttime" + }, + "where": [ + { + "group": { + "rid": "b8d28681-eba1-4156-85e2-96c9c5179fba", + "rtype": "room" + } + }, + { + "group": { + "rid": "8b529073-36dd-409b-8006-80df304048ea", + "rtype": "room" + } + } + ] + }, + "dependees": [ + { + "level": "critical", + "target": { + "rid": "b8d28681-eba1-4156-85e2-96c9c5179fba", + "rtype": "room" + }, + "type": "ResourceDependee" + }, + { + "level": "critical", + "target": { + "rid": "11ac9c82-d031-43a6-a8d5-b6efdee72fe6", + "rtype": "scene" + }, + "type": "ResourceDependee" + }, + { + "level": "critical", + "target": { + "rid": "8b529073-36dd-409b-8006-80df304048ea", + "rtype": "room" + }, + "type": "ResourceDependee" + }, + { + "level": "critical", + "target": { + "rid": "8b65c749-3ad8-435e-a7ed-94e3cc99e9d7", + "rtype": "scene" + }, + "type": "ResourceDependee" + } + ], + "enabled": true, + "id": "8d0ffbee-e24e-4d3e-b91a-5adc9ef5d49c", + "last_error": "", + "metadata": { + "name": "Coming home" + }, + "script_id": "fd60fcd1-4809-4813-b510-4a18856a595c", + "status": "running", + "type": "behavior_instance" + } + ] +}