]> git.basschouten.com Git - openhab-addons.git/commitdiff
[hue] Add support for enabling automations (#16980)
authorAndrew Fiddian-Green <software@whitebear.ch>
Mon, 19 Aug 2024 19:14:29 +0000 (20:14 +0100)
committerGitHub <noreply@github.com>
Mon, 19 Aug 2024 19:14:29 +0000 (21:14 +0200)
Signed-off-by: Andrew Fiddian-Green <software@whitebear.ch>
15 files changed:
bundles/org.openhab.binding.hue/doc/readme_v2.md
bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueBindingConstants.java
bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/api/dto/clip2/Event.java
bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/api/dto/clip2/MetaData.java
bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/api/dto/clip2/Resource.java
bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/api/dto/clip2/enums/CategoryType.java [new file with mode: 0644]
bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/api/dto/clip2/enums/ContentType.java [new file with mode: 0644]
bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/connection/Clip2Bridge.java
bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/Clip2BridgeHandler.java
bundles/org.openhab.binding.hue/src/main/resources/OH-INF/i18n/hue.properties
bundles/org.openhab.binding.hue/src/main/resources/OH-INF/thing/bridge.xml
bundles/org.openhab.binding.hue/src/main/resources/OH-INF/thing/channels.xml
bundles/org.openhab.binding.hue/src/test/java/org/openhab/binding/hue/internal/clip2/Clip2DtoTest.java
bundles/org.openhab.binding.hue/src/test/java/org/openhab/binding/hue/internal/clip2/SettersTest.java
bundles/org.openhab.binding.hue/src/test/resources/behavior_instance.json

index d9b84392f0a3a180de3c00666f58f3d207c58b87..ca6b2cacb0238bc98c267259b7138ab4f3391fd7 100644 (file)
@@ -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:
index 8ed1c5ab4213c05e907b45b95f49f90a9dd97dbc..8b338dbe3763109438d7ebed4e8b43a3bd264e0d 100644 (file)
@@ -17,6 +17,7 @@ import java.util.Set;
 
 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");
 }
index 7a27ff721fa0336f6638eb87c5b2bac34cd62dc2..9712bb593c7589788bc40ec4cc94b8a716bb8a97 100644 (file)
 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<List<Event>>() {
     }.getType();
 
-    private @Nullable List<Resource> data = new ArrayList<>();
+    private @Nullable List<Resource> 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<Resource> getData() {
         List<Resource> data = this.data;
index 7194a582229c27053ee7b8c4fe1b1797db485c3d..3a44de9e5ee93bd46c2ec7566f45699ccb7d7d2f 100644 (file)
@@ -15,6 +15,7 @@ package org.openhab.binding.hue.internal.api.dto.clip2;
 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 class MetaData {
         return name;
     }
 
+    public CategoryType getCategory() {
+        return CategoryType.of(category);
+    }
+
     public int getControlId() {
         Integer controlId = this.controlId;
         return controlId != null ? controlId.intValue() : 0;
index 6cae9c1e5904ba175a2d72e767f7e5c62f54840c..8adb111a2d4b0a512dd29d3365057fa5bf1582e1 100644 (file)
@@ -28,7 +28,9 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
 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 org.openhab.core.util.ColorUtil.Gamut;
 
 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.
+     * <p>
+     * The following content types are defined:
+     *
+     * <li><b>ADD</b> resource being added; contains (assumed) all fields</li>
+     * <li><b>DELETE</b> resource being deleted; contains id and type only</li>
+     * <li><b>UPDATE</b> resource being updated; contains id, type and changed fields</li>
+     * <li><b>ERROR</b> resource with error; contents unknown</li>
+     * <li><b>FULL_STATE</b> existing resource being downloaded; contains all fields</li>
      */
-    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<TamperReport> 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 class Resource {
         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 class Resource {
                         : 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 class Resource {
         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 class Resource {
 
     /**
      * 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<Boolean> 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 class Resource {
     }
 
     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 class Resource {
         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 (file)
index 0000000..6b27bf6
--- /dev/null
@@ -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 (file)
index 0000000..4e3901f
--- /dev/null
@@ -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
+}
index 69d7fa67caa21a3e844bd1ec0c2bff1ac2dfcc62..13230ac9510f65f9536f50699af25406019a602b 100644 (file)
@@ -921,12 +921,15 @@ public class Clip2Bridge implements Closeable {
             return;
         }
         List<Resource> resources = new ArrayList<>();
-        events.forEach(event -> resources.addAll(event.getData()));
+        events.forEach(event -> {
+            List<Resource> 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);
     }
 
index 3fdeeab9feb50c1868a04a06bdd94142e59721d8..37f7c6e8f7981e354b6f22fbe137c749ac3bb4ad 100644 (file)
@@ -27,6 +27,8 @@ import java.util.concurrent.Future;
 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.Resource;
 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.LocaleProvider;
 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.BaseBridgeHandler;
 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<String, Resource> automationsCache = new ConcurrentHashMap<>();;
+    private final Set<String> 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 class Clip2BridgeHandler extends BaseBridgeHandler {
         this.bundle = FrameworkUtil.getBundle(getClass());
         this.localeProvider = localeProvider;
         this.translationProvider = translationProvider;
+        this.automationChannelGroupUID = new ChannelGroupUID(thing.getUID(), CHANNEL_GROUP_AUTOMATION);
     }
 
     /**
@@ -265,9 +282,11 @@ public class Clip2BridgeHandler extends BaseBridgeHandler {
         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 class Clip2BridgeHandler extends BaseBridgeHandler {
 
     @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 @@ public class Clip2BridgeHandler extends BaseBridgeHandler {
         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 @@ public class Clip2BridgeHandler extends BaseBridgeHandler {
             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 @@ public class Clip2BridgeHandler extends BaseBridgeHandler {
             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<Resource> 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<Channel> newChannels = automations.stream().map(a -> createAutomationChannel(a));
+            Stream<Channel> 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<Resource> 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;
+    }
 }
index 48219ed5cca428fcd22188c45c6f00ebaa3de125..4e3c8336f1f45eb53a57ae165b2c8b978b11ab8e 100644 (file)
@@ -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
index d2ea0f4bee6eed84f31a54341119fd4748173133..f763a8d23cd0a403e557410f65aaeb4fcee5682f 100644 (file)
                <label>Hue API v2 Bridge</label>
                <description>The Hue Bridge represents a Philips Hue Bridge supporting API v2.</description>
 
+               <channel-groups>
+                       <channel-group id="automation" typeId="automation"/>
+               </channel-groups>
+
                <representation-property>serialNumber</representation-property>
 
                <config-description>
index b25510071b6ba95feebeec475235727bfada02f7..68c79045c885ab903225018b23b1e20496b2ffdf 100644 (file)
                <category>Siren</category>
        </channel-type>
 
+       <channel-type id="automation-enable">
+               <item-type>Switch</item-type>
+               <label>Enable</label>
+               <category>Switch</category>
+       </channel-type>
+
+       <channel-group-type id="automation">
+               <label>Automations</label>
+       </channel-group-type>
+
 </thing:thing-descriptions>
index 229d3bb9afa3765dbbd603804a13631ef226823e..fc7be62844895678c82c2824971ec7be6ed81d6a 100644 (file)
@@ -905,4 +905,14 @@ class Clip2DtoTest {
         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<Resource> list = resources.getResources();
+        assertNotNull(list);
+        assertEquals(2, list.size());
+    }
 }
index d005cb906fbe6a981057d7e23b68be332a759a43..2f5efa7a15c457cd0f9ee55af486e3505dc48a36 100644 (file)
@@ -30,13 +30,14 @@ import org.openhab.binding.hue.internal.api.dto.clip2.Dimming;
 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 @@ public class SettersTest {
      *
      * Expected output:
      * - Resource 1: type=light, sparse, id=1, dimming=50
-     * 
+     *
      * @throws DTOPresentButEmptyException
      */
     @Test
@@ -137,7 +138,7 @@ public class SettersTest {
      * 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 @@ public class SettersTest {
      * 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 @@ public class SettersTest {
      *
      * Expected output:
      * - Exception thrown, full state is not supported/expected.
-     * 
+     *
      * @throws DTOPresentButEmptyException
      */
     @Test
@@ -254,7 +255,7 @@ public class SettersTest {
      * 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 @@ public class SettersTest {
      * 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 @@ public class SettersTest {
      *
      * Expected output:
      * - Resource 1: type=light, sparse, id=1, on=on, color temperature=370
-     * 
+     *
      * @throws DTOPresentButEmptyException
      */
     @Test
@@ -389,7 +390,7 @@ public class SettersTest {
      *
      * Expected output:
      * - Resource 1: type=motion, sparse, id=1
-     * 
+     *
      * @throws DTOPresentButEmptyException
      */
     @Test
@@ -431,7 +432,7 @@ public class SettersTest {
     private Resource createResource(ResourceType resourceType, String id) {
         Resource resource = new Resource(resourceType);
         resource.setId(id);
-        resource.markAsSparse();
+        resource.setContentType(ContentType.UPDATE);
 
         return resource;
     }
index 2c2c4f4aa536443020c6d6a40c5c60c84cc8b25f..3da61f4dea4e80ef18b64776965e743651a52912 100644 (file)
 {
-       "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"
+        }
+    ]
+}