From 8f9a5ed8a04e6d160a1d972575d3edbff6b1d620 Mon Sep 17 00:00:00 2001 From: jlaur Date: Mon, 15 Nov 2021 23:53:23 +0100 Subject: [PATCH] [hdpowerview] Add support for scene groups (#11534) * Add support for scene collections. Fixes #11533 Signed-off-by: Jacob Laursen * Add unit test for parsing of scene collections response. Signed-off-by: Jacob Laursen * Add default i18n properties file. Signed-off-by: Jacob Laursen * Fix CAT: File does not end with a newline. Signed-off-by: Jacob Laursen * Update documentation with scene collections. Signed-off-by: Jacob Laursen * Fix CAT: File does not end with a newline. Signed-off-by: Jacob Laursen * Fix formatting. Signed-off-by: Jacob Laursen * Fix CAT: File does not end with a newline. Signed-off-by: Jacob Laursen * Split offline tests into separate distinct tests. Signed-off-by: Jacob Laursen * Increase test coverage for scene/scene collection parsing. Signed-off-by: Jacob Laursen * Internationalization of dynamic scene/scene collection channels. Signed-off-by: Jacob Laursen * Rename scene collections to scene groups. Renamed for all user-oriented texts/references to be consistent with now abandoned feature of the PowerView app. Signed-off-by: Jacob Laursen * Change custom text keys to not collide with framework. Signed-off-by: Jacob Laursen * Avoid multiple thing updates. Signed-off-by: Jacob Laursen * Add missing label/description texts for secondary channel. Signed-off-by: Jacob Laursen * Remove unneeded @Nullable annotations. Signed-off-by: Jacob Laursen Signed-off-by: Nick Waterton --- .../org.openhab.binding.hdpowerview/README.md | 7 +- .../internal/HDPowerViewBindingConstants.java | 2 + .../internal/HDPowerViewHandlerFactory.java | 13 +- .../HDPowerViewTranslationProvider.java | 47 +++++ .../internal/HDPowerViewWebTargets.java | 33 ++++ .../api/responses/SceneCollections.java | 52 ++++++ .../internal/api/responses/Scenes.java | 3 +- .../handler/HDPowerViewHubHandler.java | 130 +++++++++++--- .../OH-INF/i18n/hdpowerview.properties | 44 +++++ .../resources/OH-INF/thing/thing-types.xml | 6 +- .../hdpowerview/HDPowerViewJUnitTests.java | 164 ++++++++++-------- .../src/test/resources/sceneCollections.json | 15 ++ 12 files changed, 420 insertions(+), 96 deletions(-) create mode 100644 bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/HDPowerViewTranslationProvider.java create mode 100644 bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/api/responses/SceneCollections.java create mode 100644 bundles/org.openhab.binding.hdpowerview/src/main/resources/OH-INF/i18n/hdpowerview.properties create mode 100644 bundles/org.openhab.binding.hdpowerview/src/test/resources/sceneCollections.json diff --git a/bundles/org.openhab.binding.hdpowerview/README.md b/bundles/org.openhab.binding.hdpowerview/README.md index d387c46160d5b..60601cf6c2ec6 100644 --- a/bundles/org.openhab.binding.hdpowerview/README.md +++ b/bundles/org.openhab.binding.hdpowerview/README.md @@ -60,12 +60,13 @@ However, the configuration parameters are described below: ### Channels for PowerView Hub -Scene channels will be added dynamically to the binding as they are discovered in the hub. -Each scene channel will have an entry in the hub as shown below, whereby different scenes have different `id` values: +Scene and scene group channels will be added dynamically to the binding as they are discovered in the hub. +Each scene/scene group channel will have an entry in the hub as shown below, whereby different scenes/scene groups +have different `id` values: | Channel | Item Type | Description | |----------|-----------| ------------| -| id | Switch | Turning this to ON will activate the scene. Scenes are stateless in the PowerView hub; they have no on/off state. Note: include `{autoupdate="false"}` in the item configuration to avoid having to reset it to off after use. | +| id | Switch | Turning this to ON will activate the scene/scene group. Scenes/scene groups are stateless in the PowerView hub; they have no on/off state. Note: include `{autoupdate="false"}` in the item configuration to avoid having to reset it to off after use. | ### Channels for PowerView Shade diff --git a/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/HDPowerViewBindingConstants.java b/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/HDPowerViewBindingConstants.java index da235dd22c8da..6eb621157351e 100644 --- a/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/HDPowerViewBindingConstants.java +++ b/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/HDPowerViewBindingConstants.java @@ -26,6 +26,7 @@ * * @author Andy Lintner - Initial contribution * @author Andrew Fiddian-Green - Added support for secondary rail positions + * @author Jacob Laursen - Add support for scene groups */ @NonNullByDefault public class HDPowerViewBindingConstants { @@ -46,6 +47,7 @@ public class HDPowerViewBindingConstants { public static final String CHANNEL_SHADE_SIGNAL_STRENGTH = "signalStrength"; public static final String CHANNELTYPE_SCENE_ACTIVATE = "scene-activate"; + public static final String CHANNELTYPE_SCENE_GROUP_ACTIVATE = "scene-group-activate"; public static final List NETBIOS_NAMES = Arrays.asList("PDBU-Hub3.0", "PowerView-Hub"); diff --git a/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/HDPowerViewHandlerFactory.java b/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/HDPowerViewHandlerFactory.java index 8b4dc8aeb8be1..65083143bed56 100644 --- a/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/HDPowerViewHandlerFactory.java +++ b/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/HDPowerViewHandlerFactory.java @@ -21,6 +21,8 @@ import org.openhab.binding.hdpowerview.internal.handler.HDPowerViewHubHandler; import org.openhab.binding.hdpowerview.internal.handler.HDPowerViewShadeHandler; import org.openhab.core.config.discovery.DiscoveryService; +import org.openhab.core.i18n.LocaleProvider; +import org.openhab.core.i18n.TranslationProvider; import org.openhab.core.io.net.http.HttpClientFactory; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.Thing; @@ -28,6 +30,7 @@ import org.openhab.core.thing.binding.BaseThingHandlerFactory; import org.openhab.core.thing.binding.ThingHandler; import org.openhab.core.thing.binding.ThingHandlerFactory; +import org.osgi.service.component.ComponentContext; import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Reference; @@ -43,10 +46,16 @@ public class HDPowerViewHandlerFactory extends BaseThingHandlerFactory { private final HttpClient httpClient; + private final HDPowerViewTranslationProvider translationProvider; @Activate - public HDPowerViewHandlerFactory(@Reference HttpClientFactory httpClientFactory) { + public HDPowerViewHandlerFactory(@Reference HttpClientFactory httpClientFactory, + final @Reference TranslationProvider i18nProvider, final @Reference LocaleProvider localeProvider, + ComponentContext componentContext) { + super.activate(componentContext); this.httpClient = httpClientFactory.getCommonHttpClient(); + this.translationProvider = new HDPowerViewTranslationProvider(getBundleContext().getBundle(), i18nProvider, + localeProvider); } @Override @@ -59,7 +68,7 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { ThingTypeUID thingTypeUID = thing.getThingTypeUID(); if (thingTypeUID.equals(HDPowerViewBindingConstants.THING_TYPE_HUB)) { - HDPowerViewHubHandler handler = new HDPowerViewHubHandler((Bridge) thing, httpClient); + HDPowerViewHubHandler handler = new HDPowerViewHubHandler((Bridge) thing, httpClient, translationProvider); registerService(new HDPowerViewShadeDiscoveryService(handler)); return handler; } else if (thingTypeUID.equals(HDPowerViewBindingConstants.THING_TYPE_SHADE)) { diff --git a/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/HDPowerViewTranslationProvider.java b/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/HDPowerViewTranslationProvider.java new file mode 100644 index 0000000000000..adf16057a153a --- /dev/null +++ b/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/HDPowerViewTranslationProvider.java @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2010-2021 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.hdpowerview.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.i18n.LocaleProvider; +import org.openhab.core.i18n.TranslationProvider; +import org.osgi.framework.Bundle; + +/** + * {@link HDPowerViewTranslationProvider} provides i18n message lookup + * + * @author Jacob Laursen - Initial contribution + */ +@NonNullByDefault +public class HDPowerViewTranslationProvider { + + private final Bundle bundle; + private final TranslationProvider i18nProvider; + private final LocaleProvider localeProvider; + + public HDPowerViewTranslationProvider(Bundle bundle, TranslationProvider i18nProvider, + LocaleProvider localeProvider) { + this.bundle = bundle; + this.i18nProvider = i18nProvider; + this.localeProvider = localeProvider; + } + + public String getText(String key, @Nullable Object... arguments) { + String text = i18nProvider.getText(bundle, key, key, localeProvider.getLocale(), arguments); + if (text != null) { + return text; + } + return key; + } +} diff --git a/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/HDPowerViewWebTargets.java b/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/HDPowerViewWebTargets.java index eaa17cfbfe3ac..0afa08478341a 100644 --- a/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/HDPowerViewWebTargets.java +++ b/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/HDPowerViewWebTargets.java @@ -28,6 +28,7 @@ import org.openhab.binding.hdpowerview.internal.api.ShadePosition; import org.openhab.binding.hdpowerview.internal.api.requests.ShadeMove; import org.openhab.binding.hdpowerview.internal.api.requests.ShadeStop; +import org.openhab.binding.hdpowerview.internal.api.responses.SceneCollections; import org.openhab.binding.hdpowerview.internal.api.responses.Scenes; import org.openhab.binding.hdpowerview.internal.api.responses.Shade; import org.openhab.binding.hdpowerview.internal.api.responses.Shades; @@ -42,6 +43,7 @@ * * @author Andy Lintner - Initial contribution * @author Andrew Fiddian-Green - Added support for secondary rail positions + * @author Jacob Laursen - Add support for scene groups */ @NonNullByDefault public class HDPowerViewWebTargets { @@ -61,6 +63,8 @@ public class HDPowerViewWebTargets { private final String shades; private final String sceneActivate; private final String scenes; + private final String sceneCollectionActivate; + private final String sceneCollections; private final Gson gson = new Gson(); private final HttpClient httpClient; @@ -101,6 +105,8 @@ public HDPowerViewWebTargets(HttpClient httpClient, String ipAddress) { shades = base + "shades/"; sceneActivate = base + "scenes"; scenes = base + "scenes/"; + sceneCollectionActivate = base + "sceneCollections"; + sceneCollections = base + "sceneCollections/"; this.httpClient = httpClient; } @@ -156,6 +162,33 @@ public void activateScene(int sceneId) throws HubProcessingException, HubMainten invoke(HttpMethod.GET, sceneActivate, Query.of("sceneId", Integer.toString(sceneId)), null); } + /** + * Fetches a JSON package that describes all scene collections in the hub, and wraps it in + * a SceneCollections class instance + * + * @return SceneCollections class instance + * @throws JsonParseException if there is a JSON parsing error + * @throws HubProcessingException if there is any processing error + * @throws HubMaintenanceException if the hub is down for maintenance + */ + public @Nullable SceneCollections getSceneCollections() + throws JsonParseException, HubProcessingException, HubMaintenanceException { + String json = invoke(HttpMethod.GET, sceneCollections, null, null); + return gson.fromJson(json, SceneCollections.class); + } + + /** + * Instructs the hub to execute a specific scene collection + * + * @param sceneCollectionId id of the scene collection to be executed + * @throws HubProcessingException if there is any processing error + * @throws HubMaintenanceException if the hub is down for maintenance + */ + public void activateSceneCollection(int sceneCollectionId) throws HubProcessingException, HubMaintenanceException { + invoke(HttpMethod.GET, sceneCollectionActivate, + Query.of("sceneCollectionId", Integer.toString(sceneCollectionId)), null); + } + /** * Invoke a call on the hub server to retrieve information or send a command * diff --git a/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/api/responses/SceneCollections.java b/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/api/responses/SceneCollections.java new file mode 100644 index 0000000000000..f4822b0693c7c --- /dev/null +++ b/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/api/responses/SceneCollections.java @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2010-2021 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.hdpowerview.internal.api.responses; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * State of all Scenes in an HD PowerView hub + * + * @author Jacob Laursen - Initial contribution + */ +@NonNullByDefault +public class SceneCollections { + + public @Nullable List sceneCollectionData; + public @Nullable List sceneCollectionIds; + + /* + * the following SuppressWarnings annotation is because the Eclipse compiler + * does NOT expect a NonNullByDefault annotation on the inner class, since it is + * implicitly inherited from the outer class, whereas the Maven compiler always + * requires an explicit NonNullByDefault annotation on all classes + */ + @SuppressWarnings("null") + @NonNullByDefault + public static class SceneCollection { + public int id; + public @Nullable String name; + public int order; + public int colorId; + public int iconId; + + public String getName() { + return new String(Base64.getDecoder().decode(name), StandardCharsets.UTF_8); + } + } +} diff --git a/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/api/responses/Scenes.java b/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/api/responses/Scenes.java index 49664b0dbc57f..cf759e84fc389 100644 --- a/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/api/responses/Scenes.java +++ b/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/api/responses/Scenes.java @@ -12,6 +12,7 @@ */ package org.openhab.binding.hdpowerview.internal.api.responses; +import java.nio.charset.StandardCharsets; import java.util.Base64; import java.util.List; @@ -46,7 +47,7 @@ public static class Scene { public int iconId; public String getName() { - return new String(Base64.getDecoder().decode(name)); + return new String(Base64.getDecoder().decode(name), StandardCharsets.UTF_8); } } } diff --git a/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/handler/HDPowerViewHubHandler.java b/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/handler/HDPowerViewHubHandler.java index d617bb06cfbeb..35f1696932efa 100644 --- a/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/handler/HDPowerViewHubHandler.java +++ b/bundles/org.openhab.binding.hdpowerview/src/main/java/org/openhab/binding/hdpowerview/internal/handler/HDPowerViewHubHandler.java @@ -26,9 +26,12 @@ import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jetty.client.HttpClient; import org.openhab.binding.hdpowerview.internal.HDPowerViewBindingConstants; +import org.openhab.binding.hdpowerview.internal.HDPowerViewTranslationProvider; import org.openhab.binding.hdpowerview.internal.HDPowerViewWebTargets; import org.openhab.binding.hdpowerview.internal.HubMaintenanceException; import org.openhab.binding.hdpowerview.internal.HubProcessingException; +import org.openhab.binding.hdpowerview.internal.api.responses.SceneCollections; +import org.openhab.binding.hdpowerview.internal.api.responses.SceneCollections.SceneCollection; import org.openhab.binding.hdpowerview.internal.api.responses.Scenes; import org.openhab.binding.hdpowerview.internal.api.responses.Scenes.Scene; import org.openhab.binding.hdpowerview.internal.api.responses.Shades; @@ -59,12 +62,14 @@ * * @author Andy Lintner - Initial contribution * @author Andrew Fiddian-Green - Added support for secondary rail positions + * @author Jacob Laursen - Add support for scene groups */ @NonNullByDefault public class HDPowerViewHubHandler extends BaseBridgeHandler { private final Logger logger = LoggerFactory.getLogger(HDPowerViewHubHandler.class); private final HttpClient httpClient; + private final HDPowerViewTranslationProvider translationProvider; private long refreshInterval; private long hardRefreshPositionInterval; @@ -78,9 +83,14 @@ public class HDPowerViewHubHandler extends BaseBridgeHandler { private final ChannelTypeUID sceneChannelTypeUID = new ChannelTypeUID(HDPowerViewBindingConstants.BINDING_ID, HDPowerViewBindingConstants.CHANNELTYPE_SCENE_ACTIVATE); - public HDPowerViewHubHandler(Bridge bridge, HttpClient httpClient) { + private final ChannelTypeUID sceneCollectionChannelTypeUID = new ChannelTypeUID( + HDPowerViewBindingConstants.BINDING_ID, HDPowerViewBindingConstants.CHANNELTYPE_SCENE_GROUP_ACTIVATE); + + public HDPowerViewHubHandler(Bridge bridge, HttpClient httpClient, + HDPowerViewTranslationProvider translationProvider) { super(bridge); this.httpClient = httpClient; + this.translationProvider = translationProvider; } @Override @@ -90,21 +100,30 @@ public void handleCommand(ChannelUID channelUID, Command command) { return; } + if (!OnOffType.ON.equals(command)) { + return; + } + Channel channel = getThing().getChannel(channelUID.getId()); - if (channel != null && sceneChannelTypeUID.equals(channel.getChannelTypeUID())) { - if (OnOffType.ON.equals(command)) { - try { - HDPowerViewWebTargets webTargets = this.webTargets; - if (webTargets == null) { - throw new ProcessingException("Web targets not initialized"); - } - webTargets.activateScene(Integer.parseInt(channelUID.getId())); - } catch (HubMaintenanceException e) { - // exceptions are logged in HDPowerViewWebTargets - } catch (NumberFormatException | HubProcessingException e) { - logger.debug("Unexpected error {}", e.getMessage()); - } + if (channel == null) { + return; + } + + try { + HDPowerViewWebTargets webTargets = this.webTargets; + if (webTargets == null) { + throw new ProcessingException("Web targets not initialized"); } + int id = Integer.parseInt(channelUID.getId()); + if (sceneChannelTypeUID.equals(channel.getChannelTypeUID())) { + webTargets.activateScene(id); + } else if (sceneCollectionChannelTypeUID.equals(channel.getChannelTypeUID())) { + webTargets.activateSceneCollection(id); + } + } catch (HubMaintenanceException e) { + // exceptions are logged in HDPowerViewWebTargets + } catch (NumberFormatException | HubProcessingException e) { + logger.debug("Unexpected error {}", e.getMessage()); } } @@ -115,7 +134,8 @@ public void initialize() { String host = config.host; if (host == null || host.isEmpty()) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Host address must be set"); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "@text/offline.conf-error-no-host-address"); return; } @@ -196,6 +216,7 @@ private synchronized void poll() { logger.debug("Polling for state"); pollShades(); pollScenes(); + pollSceneCollections(); } catch (JsonParseException e) { logger.warn("Bridge returned a bad JSON response: {}", e.getMessage()); } catch (HubProcessingException e) { @@ -266,7 +287,9 @@ private void pollScenes() throws JsonParseException, HubProcessingException, Hub } logger.debug("Received data for {} scenes", sceneData.size()); - Map idChannelMap = getIdChannelMap(); + Map idChannelMap = getIdSceneChannelMap(); + List allChannels = new ArrayList<>(getThing().getChannels()); + boolean isChannelListChanged = false; for (Scene scene : sceneData) { // remove existing scene channel from the map String sceneId = Integer.toString(scene.id); @@ -276,9 +299,12 @@ private void pollScenes() throws JsonParseException, HubProcessingException, Hub } else { // create a new scene channel ChannelUID channelUID = new ChannelUID(getThing().getUID(), sceneId); + String description = translationProvider.getText("dynamic-channel.scene-activate.description", + scene.getName()); Channel channel = ChannelBuilder.create(channelUID, "Switch").withType(sceneChannelTypeUID) - .withLabel(scene.getName()).withDescription("Activates the scene " + scene.getName()).build(); - updateThing(editThing().withChannel(channel).build()); + .withLabel(scene.getName()).withDescription(description).build(); + allChannels.add(channel); + isChannelListChanged = true; logger.debug("Creating new channel for scene '{}'", sceneId); } } @@ -286,8 +312,62 @@ private void pollScenes() throws JsonParseException, HubProcessingException, Hub // remove any previously created channels that no longer exist if (!idChannelMap.isEmpty()) { logger.debug("Removing {} orphan scene channels", idChannelMap.size()); - List allChannels = new ArrayList<>(getThing().getChannels()); allChannels.removeAll(idChannelMap.values()); + isChannelListChanged = true; + } + + if (isChannelListChanged) { + updateThing(editThing().withChannels(allChannels).build()); + } + } + + private void pollSceneCollections() throws JsonParseException, HubProcessingException, HubMaintenanceException { + HDPowerViewWebTargets webTargets = this.webTargets; + if (webTargets == null) { + throw new ProcessingException("Web targets not initialized"); + } + + SceneCollections sceneCollections = webTargets.getSceneCollections(); + if (sceneCollections == null) { + throw new JsonParseException("Missing 'sceneCollections' element"); + } + + List sceneCollectionData = sceneCollections.sceneCollectionData; + if (sceneCollectionData == null) { + throw new JsonParseException("Missing 'sceneCollections.sceneCollectionData' element"); + } + logger.debug("Received data for {} sceneCollections", sceneCollectionData.size()); + + Map idChannelMap = getIdSceneCollectionChannelMap(); + List allChannels = new ArrayList<>(getThing().getChannels()); + boolean isChannelListChanged = false; + for (SceneCollection sceneCollection : sceneCollectionData) { + // remove existing scene collection channel from the map + String sceneCollectionId = Integer.toString(sceneCollection.id); + if (idChannelMap.containsKey(sceneCollectionId)) { + idChannelMap.remove(sceneCollectionId); + logger.debug("Keeping channel for existing scene collection '{}'", sceneCollectionId); + } else { + // create a new scene collection channel + ChannelUID channelUID = new ChannelUID(getThing().getUID(), sceneCollectionId); + String description = translationProvider.getText("dynamic-channel.scene-group-activate.description", + sceneCollection.getName()); + Channel channel = ChannelBuilder.create(channelUID, "Switch").withType(sceneCollectionChannelTypeUID) + .withLabel(sceneCollection.getName()).withDescription(description).build(); + allChannels.add(channel); + isChannelListChanged = true; + logger.debug("Creating new channel for scene collection '{}'", sceneCollectionId); + } + } + + // remove any previously created channels that no longer exist + if (!idChannelMap.isEmpty()) { + logger.debug("Removing {} orphan scene collection channels", idChannelMap.size()); + allChannels.removeAll(idChannelMap.values()); + isChannelListChanged = true; + } + + if (isChannelListChanged) { updateThing(editThing().withChannels(allChannels).build()); } } @@ -313,7 +393,7 @@ private Map getIdShadeDataMap(List shadeData) { return ret; } - private Map getIdChannelMap() { + private Map getIdSceneChannelMap() { Map ret = new HashMap<>(); for (Channel channel : getThing().getChannels()) { if (sceneChannelTypeUID.equals(channel.getChannelTypeUID())) { @@ -323,6 +403,16 @@ private Map getIdChannelMap() { return ret; } + private Map getIdSceneCollectionChannelMap() { + Map ret = new HashMap<>(); + for (Channel channel : getThing().getChannels()) { + if (sceneCollectionChannelTypeUID.equals(channel.getChannelTypeUID())) { + ret.put(channel.getUID().getId(), channel); + } + } + return ret; + } + private void requestRefreshShadePositions() { Map thingIdMap = getThingIdMap(); for (Entry item : thingIdMap.entrySet()) { diff --git a/bundles/org.openhab.binding.hdpowerview/src/main/resources/OH-INF/i18n/hdpowerview.properties b/bundles/org.openhab.binding.hdpowerview/src/main/resources/OH-INF/i18n/hdpowerview.properties new file mode 100644 index 0000000000000..34875ac80c592 --- /dev/null +++ b/bundles/org.openhab.binding.hdpowerview/src/main/resources/OH-INF/i18n/hdpowerview.properties @@ -0,0 +1,44 @@ +# binding + +binding.hdpowerview.name = Hunter Douglas PowerView Binding +binding.hdpowerview.description = The Hunter Douglas PowerView binding provides access to the Hunter Douglas line of PowerView shades. + +# thing types + +thing-type.hdpowerview.hub.label = PowerView Hub +thing-type.hdpowerview.hub.description = Hunter Douglas (Luxaflex) PowerView Hub +thing-type.hdpowerview.shade.label = PowerView Shade +thing-type.hdpowerview.shade.description = Hunter Douglas (Luxaflex) PowerView Shade +thing-type.hdpowerview.shade.channel.secondary.label = Secondary Position +thing-type.hdpowerview.shade.channel.secondary.description = The secondary vertical position (on top-down/bottom-up shades) + +# thing types config + +thing-type.config.hdpowerview.hub.hardRefresh.label = Hard Position Refresh Interval +thing-type.config.hdpowerview.hub.hardRefresh.description = The number of minutes between hard refreshes of positions from the PowerView Hub (or 0 to disable) +thing-type.config.hdpowerview.hub.hardRefreshBatteryLevel.label = Hard Battery Level Refresh Interval +thing-type.config.hdpowerview.hub.hardRefreshBatteryLevel.description = The number of hours between hard refreshes of battery levels from the PowerView Hub (or 0 to disable, default is weekly) +thing-type.config.hdpowerview.hub.host.label = Host +thing-type.config.hdpowerview.hub.host.description = The Host address of the PowerView Hub +thing-type.config.hdpowerview.hub.refresh.label = Refresh Interval +thing-type.config.hdpowerview.hub.refresh.description = The number of milliseconds between fetches of the PowerView Hub shade state +thing-type.config.hdpowerview.shade.id.label = ID +thing-type.config.hdpowerview.shade.id.description = The numeric ID of the PowerView Shade in the Hub + +# channel types + +channel-type.hdpowerview.battery-voltage.label = Battery Voltage +channel-type.hdpowerview.battery-voltage.description = Battery voltage reported by the shade +channel-type.hdpowerview.shade-position.label = Position +channel-type.hdpowerview.shade-position.description = The vertical position of the shade +channel-type.hdpowerview.shade-vane.label = Vane +channel-type.hdpowerview.shade-vane.description = The opening of the slats in the shade + +# thing status descriptions + +offline.conf-error-no-host-address = Host address must be set + +# dynamic channels + +dynamic-channel.scene-activate.description = Activates the scene ''{0}'' +dynamic-channel.scene-group-activate.description = Activates the scene group ''{0}'' diff --git a/bundles/org.openhab.binding.hdpowerview/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.hdpowerview/src/main/resources/OH-INF/thing/thing-types.xml index c852a49b75299..22954d4097f20 100644 --- a/bundles/org.openhab.binding.hdpowerview/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.hdpowerview/src/main/resources/OH-INF/thing/thing-types.xml @@ -89,7 +89,11 @@ Switch - Activates the scene + + + + Switch + diff --git a/bundles/org.openhab.binding.hdpowerview/src/test/java/org/openhab/binding/hdpowerview/HDPowerViewJUnitTests.java b/bundles/org.openhab.binding.hdpowerview/src/test/java/org/openhab/binding/hdpowerview/HDPowerViewJUnitTests.java index 14eb6c0aaa595..8bd12d92b3a2d 100644 --- a/bundles/org.openhab.binding.hdpowerview/src/test/java/org/openhab/binding/hdpowerview/HDPowerViewJUnitTests.java +++ b/bundles/org.openhab.binding.hdpowerview/src/test/java/org/openhab/binding/hdpowerview/HDPowerViewJUnitTests.java @@ -16,11 +16,12 @@ import static org.openhab.binding.hdpowerview.internal.api.ActuatorClass.*; import static org.openhab.binding.hdpowerview.internal.api.CoordinateSystem.*; -import java.io.BufferedReader; -import java.io.FileReader; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; import java.util.List; import java.util.regex.Pattern; +import java.util.stream.Collectors; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; @@ -31,6 +32,8 @@ import org.openhab.binding.hdpowerview.internal.HubProcessingException; import org.openhab.binding.hdpowerview.internal.api.CoordinateSystem; import org.openhab.binding.hdpowerview.internal.api.ShadePosition; +import org.openhab.binding.hdpowerview.internal.api.responses.SceneCollections; +import org.openhab.binding.hdpowerview.internal.api.responses.SceneCollections.SceneCollection; import org.openhab.binding.hdpowerview.internal.api.responses.Scenes; import org.openhab.binding.hdpowerview.internal.api.responses.Scenes.Scene; import org.openhab.binding.hdpowerview.internal.api.responses.Shade; @@ -47,6 +50,7 @@ * Unit tests for HD PowerView binding * * @author Andrew Fiddian-Green - Initial contribution + * @author Jacob Laursen - Add support for scene groups */ @NonNullByDefault public class HDPowerViewJUnitTests { @@ -58,14 +62,9 @@ public class HDPowerViewJUnitTests { * load a test JSON string from a file */ private String loadJson(String fileName) { - try (FileReader file = new FileReader(String.format("src/test/resources/%s.json", fileName)); - BufferedReader reader = new BufferedReader(file)) { - StringBuilder builder = new StringBuilder(); - String line; - while ((line = reader.readLine()) != null) { - builder.append(line).append("\n"); - } - return builder.toString(); + try { + return Files.readAllLines(Paths.get(String.format("src/test/resources/%s.json", fileName))).stream() + .collect(Collectors.joining()); } catch (IOException e) { fail(e.getMessage()); } @@ -287,80 +286,107 @@ public void testOnlineCommunication() { } /** - * Run a series of OFFLINE tests on the JSON parsing machinery + * Test generic JSON shades response */ @Test - public void testOfflineJsonParsing() { + public void shadeResponseIsParsedCorrectly() throws JsonParseException { final Gson gson = new Gson(); - @Nullable Shades shades; - // test generic JSON shades response - try { - @Nullable - String json = loadJson("shades"); - assertNotNull(json); - assertNotEquals("", json); - shades = gson.fromJson(json, Shades.class); - assertNotNull(shades); - } catch (JsonParseException e) { - fail(e.getMessage()); - } + String json = loadJson("shades"); + assertNotEquals("", json); + shades = gson.fromJson(json, Shades.class); + assertNotNull(shades); + } - // test generic JSON scenes response - try { - @Nullable - String json = loadJson("scenes"); - assertNotNull(json); - assertNotEquals("", json); - @Nullable - Scenes scenes = gson.fromJson(json, Scenes.class); - assertNotNull(scenes); - } catch (JsonParseException e) { - fail(e.getMessage()); - } + /** + * Test generic JSON scene response + */ + @Test + public void sceneResponseIsParsedCorrectly() throws JsonParseException { + final Gson gson = new Gson(); + String json = loadJson("scenes"); + assertNotEquals("", json); - // test the JSON parsing for a duette top down bottom up shade - try { - @Nullable - ShadeData shadeData = null; - String json = loadJson("duette"); - assertNotNull(json); - assertNotEquals("", json); + @Nullable + Scenes scenes = gson.fromJson(json, Scenes.class); + assertNotNull(scenes); - shades = gson.fromJson(json, Shades.class); - assertNotNull(shades); - @Nullable - List shadesData = shades.shadeData; - assertNotNull(shadesData); + @Nullable + List sceneData = scenes.sceneData; + assertNotNull(sceneData); - assertEquals(1, shadesData.size()); - shadeData = shadesData.get(0); - assertNotNull(shadeData); + assertEquals(4, sceneData.size()); + @Nullable + Scene scene = sceneData.get(0); + assertEquals("Door Open", scene.getName()); + assertEquals(18097, scene.id); + } - assertEquals("Gardin 1", shadeData.getName()); - assertEquals(63778, shadeData.id); + /** + * Test generic JSON scene collection response + */ + @Test + public void sceneCollectionResponseIsParsedCorrectly() throws JsonParseException { + final Gson gson = new Gson(); + String json = loadJson("sceneCollections"); + assertNotEquals("", json); - ShadePosition shadePos = shadeData.positions; - assertNotNull(shadePos); - assertEquals(ZERO_IS_CLOSED, shadePos.getCoordinateSystem(PRIMARY_ACTUATOR)); + @Nullable + SceneCollections sceneCollections = gson.fromJson(json, SceneCollections.class); + assertNotNull(sceneCollections); + @Nullable + List sceneCollectionData = sceneCollections.sceneCollectionData; + assertNotNull(sceneCollectionData); - State pos = shadePos.getState(PRIMARY_ACTUATOR, ZERO_IS_CLOSED); - assertEquals(PercentType.class, pos.getClass()); - assertEquals(59, ((PercentType) pos).intValue()); + assertEquals(1, sceneCollectionData.size()); + @Nullable + SceneCollection sceneCollection = sceneCollectionData.get(0); + assertEquals("Børn op", sceneCollection.getName()); + assertEquals(27119, sceneCollection.id); + } - pos = shadePos.getState(SECONDARY_ACTUATOR, ZERO_IS_OPEN); - assertEquals(PercentType.class, pos.getClass()); - assertEquals(35, ((PercentType) pos).intValue()); + /** + * Test the JSON parsing for a duette top down bottom up shade + */ + @Test + public void duetteTopDownBottomUpShadeIsParsedCorrectly() throws JsonParseException { + final Gson gson = new Gson(); + String json = loadJson("duette"); + assertNotEquals("", json); - pos = shadePos.getState(PRIMARY_ACTUATOR, VANE_COORDS); - assertEquals(UnDefType.class, pos.getClass()); + @Nullable + Shades shades = gson.fromJson(json, Shades.class); + assertNotNull(shades); + @Nullable + List shadesData = shades.shadeData; + assertNotNull(shadesData); - assertEquals(3, shadeData.batteryStatus); + assertEquals(1, shadesData.size()); + @Nullable + ShadeData shadeData = shadesData.get(0); + assertNotNull(shadeData); - assertEquals(4, shadeData.signalStrength); - } catch (JsonParseException e) { - fail(e.getMessage()); - } + assertEquals("Gardin 1", shadeData.getName()); + assertEquals(63778, shadeData.id); + + ShadePosition shadePos = shadeData.positions; + assertNotNull(shadePos); + assertEquals(ZERO_IS_CLOSED, shadePos.getCoordinateSystem(PRIMARY_ACTUATOR)); + + State pos = shadePos.getState(PRIMARY_ACTUATOR, ZERO_IS_CLOSED); + assertEquals(PercentType.class, pos.getClass()); + assertEquals(59, ((PercentType) pos).intValue()); + + pos = shadePos.getState(SECONDARY_ACTUATOR, ZERO_IS_OPEN); + assertEquals(PercentType.class, pos.getClass()); + assertEquals(35, ((PercentType) pos).intValue()); + + pos = shadePos.getState(PRIMARY_ACTUATOR, VANE_COORDS); + assertEquals(UnDefType.class, pos.getClass()); + + assertEquals(3, shadeData.batteryStatus); + + assertEquals(4, shadeData.signalStrength); } } diff --git a/bundles/org.openhab.binding.hdpowerview/src/test/resources/sceneCollections.json b/bundles/org.openhab.binding.hdpowerview/src/test/resources/sceneCollections.json new file mode 100644 index 0000000000000..7631f8907720b --- /dev/null +++ b/bundles/org.openhab.binding.hdpowerview/src/test/resources/sceneCollections.json @@ -0,0 +1,15 @@ +{ + "sceneCollectionIds": [ + 27119 + ], + "sceneCollectionData": [ + { + "name": "QsO4cm4gb3A=", + "colorId": 12, + "iconId": 17, + "id": 27119, + "order": 0, + "hkAssist": false + } + ] +}