*/
package org.openhab.binding.hdpowerview.internal.handler;
+import java.time.DayOfWeek;
+import java.time.LocalTime;
+import java.time.format.TextStyle;
import java.util.ArrayList;
+import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
+import java.util.StringJoiner;
+import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
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.ScheduledEvents;
+import org.openhab.binding.hdpowerview.internal.api.responses.ScheduledEvents.ScheduledEvent;
import org.openhab.binding.hdpowerview.internal.api.responses.Shades;
import org.openhab.binding.hdpowerview.internal.api.responses.Shades.ShadeData;
import org.openhab.binding.hdpowerview.internal.config.HDPowerViewHubConfiguration;
import org.openhab.binding.hdpowerview.internal.config.HDPowerViewShadeConfiguration;
+import org.openhab.core.library.CoreItemFactory;
import org.openhab.core.library.types.OnOffType;
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.ThingStatus;
*
* @author Andy Lintner - Initial contribution
* @author Andrew Fiddian-Green - Added support for secondary rail positions
- * @author Jacob Laursen - Add support for scene groups
+ * @author Jacob Laursen - Add support for scene groups and automations
*/
@NonNullByDefault
public class HDPowerViewHubHandler extends BaseBridgeHandler {
private @Nullable ScheduledFuture<?> hardRefreshPositionFuture;
private @Nullable ScheduledFuture<?> hardRefreshBatteryLevelFuture;
+ private List<Scene> sceneCache = new CopyOnWriteArrayList<>();
+ private List<SceneCollection> sceneCollectionCache = new CopyOnWriteArrayList<>();
+ private List<ScheduledEvent> scheduledEventCache = new CopyOnWriteArrayList<>();
+ private Boolean deprecatedChannelsCreated = false;
+
private final ChannelTypeUID sceneChannelTypeUID = new ChannelTypeUID(HDPowerViewBindingConstants.BINDING_ID,
HDPowerViewBindingConstants.CHANNELTYPE_SCENE_ACTIVATE);
- private final ChannelTypeUID sceneCollectionChannelTypeUID = new ChannelTypeUID(
- HDPowerViewBindingConstants.BINDING_ID, HDPowerViewBindingConstants.CHANNELTYPE_SCENE_GROUP_ACTIVATE);
+ private final ChannelTypeUID sceneGroupChannelTypeUID = new ChannelTypeUID(HDPowerViewBindingConstants.BINDING_ID,
+ HDPowerViewBindingConstants.CHANNELTYPE_SCENE_GROUP_ACTIVATE);
+
+ private final ChannelTypeUID automationChannelTypeUID = new ChannelTypeUID(HDPowerViewBindingConstants.BINDING_ID,
+ HDPowerViewBindingConstants.CHANNELTYPE_AUTOMATION_ENABLED);
public HDPowerViewHubHandler(Bridge bridge, HttpClient httpClient,
HDPowerViewTranslationProvider translationProvider) {
return;
}
- if (!OnOffType.ON.equals(command)) {
- return;
- }
-
Channel channel = getThing().getChannel(channelUID.getId());
if (channel == null) {
return;
if (webTargets == null) {
throw new ProcessingException("Web targets not initialized");
}
- int id = Integer.parseInt(channelUID.getId());
- if (sceneChannelTypeUID.equals(channel.getChannelTypeUID())) {
+ int id = Integer.parseInt(channelUID.getIdWithoutGroup());
+ if (sceneChannelTypeUID.equals(channel.getChannelTypeUID()) && OnOffType.ON.equals(command)) {
webTargets.activateScene(id);
- } else if (sceneCollectionChannelTypeUID.equals(channel.getChannelTypeUID())) {
+ } else if (sceneGroupChannelTypeUID.equals(channel.getChannelTypeUID()) && OnOffType.ON.equals(command)) {
webTargets.activateSceneCollection(id);
+ } else if (automationChannelTypeUID.equals(channel.getChannelTypeUID())) {
+ webTargets.enableScheduledEvent(id, OnOffType.ON.equals(command));
}
} catch (HubMaintenanceException e) {
// exceptions are logged in HDPowerViewWebTargets
refreshInterval = config.refresh;
hardRefreshPositionInterval = config.hardRefresh;
hardRefreshBatteryLevelInterval = config.hardRefreshBatteryLevel;
+ initializeChannels();
schedulePoll();
}
+ private void initializeChannels() {
+ // Rebuild dynamic channels and synchronize with cache.
+ updateThing(editThing().withChannels(new ArrayList<Channel>()).build());
+ sceneCache.clear();
+ sceneCollectionCache.clear();
+ scheduledEventCache.clear();
+ }
+
public @Nullable HDPowerViewWebTargets getWebTargets() {
return webTargets;
}
try {
logger.debug("Polling for state");
pollShades();
- pollScenes();
- pollSceneCollections();
+
+ List<Scene> scenes = updateSceneChannels();
+ List<SceneCollection> sceneCollections = updateSceneCollectionChannels();
+ List<ScheduledEvent> scheduledEvents = updateScheduledEventChannels(scenes, sceneCollections);
+
+ // Scheduled events should also have their current state updated if event has been
+ // enabled or disabled through app or other integration.
+ updateScheduledEventStates(scheduledEvents);
} catch (JsonParseException e) {
logger.warn("Bridge returned a bad JSON response: {}", e.getMessage());
} catch (HubProcessingException e) {
thingHandler.onReceiveUpdate(shadeData);
}
- private void pollScenes() throws JsonParseException, HubProcessingException, HubMaintenanceException {
+ private List<Scene> fetchScenes() throws JsonParseException, HubProcessingException, HubMaintenanceException {
HDPowerViewWebTargets webTargets = this.webTargets;
if (webTargets == null) {
throw new ProcessingException("Web targets not initialized");
}
logger.debug("Received data for {} scenes", sceneData.size());
- Map<String, Channel> idChannelMap = getIdSceneChannelMap();
- List<Channel> 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);
- if (idChannelMap.containsKey(sceneId)) {
- idChannelMap.remove(sceneId);
- logger.debug("Keeping channel for existing scene '{}'", sceneId);
- } 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(description).build();
- allChannels.add(channel);
- isChannelListChanged = true;
- logger.debug("Creating new channel for scene '{}'", sceneId);
- }
- }
+ return sceneData;
+ }
+
+ private List<Scene> updateSceneChannels()
+ throws JsonParseException, HubProcessingException, HubMaintenanceException {
+ List<Scene> scenes = fetchScenes();
- // remove any previously created channels that no longer exist
- if (!idChannelMap.isEmpty()) {
- logger.debug("Removing {} orphan scene channels", idChannelMap.size());
- allChannels.removeAll(idChannelMap.values());
- isChannelListChanged = true;
+ if (scenes.size() == sceneCache.size() && sceneCache.containsAll(scenes)) {
+ // Duplicates are not allowed. Reordering is not supported.
+ logger.debug("Preserving scene channels, no changes detected");
+ return scenes;
}
- if (isChannelListChanged) {
- updateThing(editThing().withChannels(allChannels).build());
+ logger.debug("Updating all scene channels, changes detected");
+ sceneCache = new CopyOnWriteArrayList<Scene>(scenes);
+
+ List<Channel> allChannels = new ArrayList<>(getThing().getChannels());
+ allChannels.removeIf(c -> HDPowerViewBindingConstants.CHANNEL_GROUP_SCENES.equals(c.getUID().getGroupId()));
+ scenes.stream().sorted().forEach(scene -> allChannels.add(createSceneChannel(scene)));
+ updateThing(editThing().withChannels(allChannels).build());
+
+ createDeprecatedSceneChannels(scenes);
+
+ return scenes;
+ }
+
+ private Channel createSceneChannel(Scene scene) {
+ ChannelGroupUID channelGroupUid = new ChannelGroupUID(thing.getUID(),
+ HDPowerViewBindingConstants.CHANNEL_GROUP_SCENES);
+ ChannelUID channelUid = new ChannelUID(channelGroupUid, Integer.toString(scene.id));
+ String description = translationProvider.getText("dynamic-channel.scene-activate.description", scene.getName());
+ Channel channel = ChannelBuilder.create(channelUid, CoreItemFactory.SWITCH).withType(sceneChannelTypeUID)
+ .withLabel(scene.getName()).withDescription(description).build();
+
+ return channel;
+ }
+
+ /**
+ * Create backwards compatible scene channels if any items configured before release 3.2
+ * are still linked. Users should have a reasonable amount of time to migrate to the new
+ * scene channels that are connected to a channel group.
+ */
+ private void createDeprecatedSceneChannels(List<Scene> scenes) {
+ if (deprecatedChannelsCreated) {
+ // Only do this once.
+ return;
}
+ ChannelGroupUID channelGroupUid = new ChannelGroupUID(thing.getUID(),
+ HDPowerViewBindingConstants.CHANNEL_GROUP_SCENES);
+ for (Scene scene : scenes) {
+ String channelId = Integer.toString(scene.id);
+ ChannelUID newChannelUid = new ChannelUID(channelGroupUid, channelId);
+ ChannelUID deprecatedChannelUid = new ChannelUID(getThing().getUID(), channelId);
+ String description = translationProvider.getText("dynamic-channel.scene-activate.deprecated.description",
+ scene.getName());
+ Channel channel = ChannelBuilder.create(deprecatedChannelUid, CoreItemFactory.SWITCH)
+ .withType(sceneChannelTypeUID).withLabel(scene.getName()).withDescription(description).build();
+ logger.debug("Creating deprecated channel '{}' ('{}') to probe for linked items", deprecatedChannelUid,
+ scene.getName());
+ updateThing(editThing().withChannel(channel).build());
+ if (this.isLinked(deprecatedChannelUid) && !this.isLinked(newChannelUid)) {
+ logger.warn("Created deprecated channel '{}' ('{}'), please link items to '{}' instead",
+ deprecatedChannelUid, scene.getName(), newChannelUid);
+ } else {
+ if (this.isLinked(newChannelUid)) {
+ logger.debug("Removing deprecated channel '{}' ('{}') since new channel '{}' is linked",
+ deprecatedChannelUid, scene.getName(), newChannelUid);
+
+ } else {
+ logger.debug("Removing deprecated channel '{}' ('{}') since it has no linked items",
+ deprecatedChannelUid, scene.getName());
+ }
+ updateThing(editThing().withoutChannel(deprecatedChannelUid).build());
+ }
+ }
+ deprecatedChannelsCreated = true;
}
- private void pollSceneCollections() throws JsonParseException, HubProcessingException, HubMaintenanceException {
+ private List<SceneCollection> fetchSceneCollections()
+ throws JsonParseException, HubProcessingException, HubMaintenanceException {
HDPowerViewWebTargets webTargets = this.webTargets;
if (webTargets == null) {
throw new ProcessingException("Web targets not initialized");
}
logger.debug("Received data for {} sceneCollections", sceneCollectionData.size());
- Map<String, Channel> idChannelMap = getIdSceneCollectionChannelMap();
+ return sceneCollectionData;
+ }
+
+ private List<SceneCollection> updateSceneCollectionChannels()
+ throws JsonParseException, HubProcessingException, HubMaintenanceException {
+ List<SceneCollection> sceneCollections = fetchSceneCollections();
+
+ if (sceneCollections.size() == sceneCollectionCache.size()
+ && sceneCollectionCache.containsAll(sceneCollections)) {
+ // Duplicates are not allowed. Reordering is not supported.
+ logger.debug("Preserving scene collection channels, no changes detected");
+ return sceneCollections;
+ }
+
+ logger.debug("Updating all scene collection channels, changes detected");
+ sceneCollectionCache = new CopyOnWriteArrayList<SceneCollection>(sceneCollections);
+
List<Channel> 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
+ .removeIf(c -> HDPowerViewBindingConstants.CHANNEL_GROUP_SCENE_GROUPS.equals(c.getUID().getGroupId()));
+ sceneCollections.stream().sorted()
+ .forEach(sceneCollection -> allChannels.add(createSceneCollectionChannel(sceneCollection)));
+ updateThing(editThing().withChannels(allChannels).build());
+
+ return sceneCollections;
+ }
+
+ private Channel createSceneCollectionChannel(SceneCollection sceneCollection) {
+ ChannelGroupUID channelGroupUid = new ChannelGroupUID(thing.getUID(),
+ HDPowerViewBindingConstants.CHANNEL_GROUP_SCENE_GROUPS);
+ ChannelUID channelUid = new ChannelUID(channelGroupUid, Integer.toString(sceneCollection.id));
+ String description = translationProvider.getText("dynamic-channel.scene-group-activate.description",
+ sceneCollection.getName());
+ Channel channel = ChannelBuilder.create(channelUid, CoreItemFactory.SWITCH).withType(sceneGroupChannelTypeUID)
+ .withLabel(sceneCollection.getName()).withDescription(description).build();
+
+ return channel;
+ }
+
+ private List<ScheduledEvent> fetchScheduledEvents()
+ throws JsonParseException, HubProcessingException, HubMaintenanceException {
+ HDPowerViewWebTargets webTargets = this.webTargets;
+ if (webTargets == null) {
+ throw new ProcessingException("Web targets not initialized");
+ }
+
+ ScheduledEvents scheduledEvents = webTargets.getScheduledEvents();
+ if (scheduledEvents == null) {
+ throw new JsonParseException("Missing 'scheduledEvents' element");
+ }
+
+ List<ScheduledEvent> scheduledEventData = scheduledEvents.scheduledEventData;
+ if (scheduledEventData == null) {
+ throw new JsonParseException("Missing 'scheduledEvents.scheduledEventData' element");
+ }
+ logger.debug("Received data for {} scheduledEvents", scheduledEventData.size());
+
+ return scheduledEventData;
+ }
+
+ private List<ScheduledEvent> updateScheduledEventChannels(List<Scene> scenes,
+ List<SceneCollection> sceneCollections)
+ throws JsonParseException, HubProcessingException, HubMaintenanceException {
+ List<ScheduledEvent> scheduledEvents = fetchScheduledEvents();
+
+ if (scheduledEvents.size() == scheduledEventCache.size() && scheduledEventCache.containsAll(scheduledEvents)) {
+ // Duplicates are not allowed. Reordering is not supported.
+ logger.debug("Preserving scheduled event channels, no changes detected");
+ return scheduledEvents;
+ }
+
+ logger.debug("Updating all scheduled event channels, changes detected");
+ scheduledEventCache = new CopyOnWriteArrayList<ScheduledEvent>(scheduledEvents);
+
+ List<Channel> allChannels = new ArrayList<>(getThing().getChannels());
+ allChannels
+ .removeIf(c -> HDPowerViewBindingConstants.CHANNEL_GROUP_AUTOMATIONS.equals(c.getUID().getGroupId()));
+ scheduledEvents.stream().forEach(scheduledEvent -> {
+ Channel channel = createScheduledEventChannel(scheduledEvent, scenes, sceneCollections);
+ if (channel != null) {
allChannels.add(channel);
- isChannelListChanged = true;
- logger.debug("Creating new channel for scene collection '{}'", sceneCollectionId);
}
+ });
+ updateThing(editThing().withChannels(allChannels).build());
+
+ return scheduledEvents;
+ }
+
+ private @Nullable Channel createScheduledEventChannel(ScheduledEvent scheduledEvent, List<Scene> scenes,
+ List<SceneCollection> sceneCollections) {
+ String referencedName = getReferencedSceneOrSceneCollectionName(scheduledEvent, scenes, sceneCollections);
+ if (referencedName == null) {
+ return null;
+ }
+ ChannelGroupUID channelGroupUid = new ChannelGroupUID(thing.getUID(),
+ HDPowerViewBindingConstants.CHANNEL_GROUP_AUTOMATIONS);
+ ChannelUID channelUid = new ChannelUID(channelGroupUid, Integer.toString(scheduledEvent.id));
+ String label = getScheduledEventName(referencedName, scheduledEvent);
+ String description = translationProvider.getText("dynamic-channel.automation-enabled.description",
+ referencedName);
+ Channel channel = ChannelBuilder.create(channelUid, CoreItemFactory.SWITCH).withType(automationChannelTypeUID)
+ .withLabel(label).withDescription(description).build();
+
+ return channel;
+ }
+
+ private @Nullable String getReferencedSceneOrSceneCollectionName(ScheduledEvent scheduledEvent, List<Scene> scenes,
+ List<SceneCollection> sceneCollections) {
+ if (scheduledEvent.sceneId > 0) {
+ for (Scene scene : scenes) {
+ if (scene.id == scheduledEvent.sceneId) {
+ return scene.getName();
+ }
+ }
+ logger.error("Scene '{}' was not found for scheduled event '{}'", scheduledEvent.sceneId,
+ scheduledEvent.id);
+ return null;
+ } else if (scheduledEvent.sceneCollectionId > 0) {
+ for (SceneCollection sceneCollection : sceneCollections) {
+ if (sceneCollection.id == scheduledEvent.sceneCollectionId) {
+ return sceneCollection.getName();
+ }
+ }
+ logger.error("Scene collection '{}' was not found for scheduled event '{}'",
+ scheduledEvent.sceneCollectionId, scheduledEvent.id);
+ return null;
+ } else {
+ logger.error("Scheduled event '{}'' not related to any scene or scene collection", scheduledEvent.id);
+ return null;
}
+ }
- // 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;
+ private String getScheduledEventName(String sceneName, ScheduledEvent scheduledEvent) {
+ String timeString, daysString;
+
+ switch (scheduledEvent.eventType) {
+ case ScheduledEvents.SCHEDULED_EVENT_TYPE_TIME:
+ timeString = LocalTime.of(scheduledEvent.hour, scheduledEvent.minute).toString();
+ break;
+ case ScheduledEvents.SCHEDULED_EVENT_TYPE_SUNRISE:
+ if (scheduledEvent.minute == 0) {
+ timeString = translationProvider.getText("dynamic-channel.automation.at_sunrise");
+ } else if (scheduledEvent.minute < 0) {
+ timeString = translationProvider.getText("dynamic-channel.automation.before_sunrise",
+ getFormattedTimeOffset(-scheduledEvent.minute));
+ } else {
+ timeString = translationProvider.getText("dynamic-channel.automation.after_sunrise",
+ getFormattedTimeOffset(scheduledEvent.minute));
+ }
+ break;
+ case ScheduledEvents.SCHEDULED_EVENT_TYPE_SUNSET:
+ if (scheduledEvent.minute == 0) {
+ timeString = translationProvider.getText("dynamic-channel.automation.at_sunset");
+ } else if (scheduledEvent.minute < 0) {
+ timeString = translationProvider.getText("dynamic-channel.automation.before_sunset",
+ getFormattedTimeOffset(-scheduledEvent.minute));
+ } else {
+ timeString = translationProvider.getText("dynamic-channel.automation.after_sunset",
+ getFormattedTimeOffset(scheduledEvent.minute));
+ }
+ break;
+ default:
+ return sceneName;
+ }
+
+ EnumSet<DayOfWeek> days = scheduledEvent.getDays();
+ if (EnumSet.allOf(DayOfWeek.class).equals(days)) {
+ daysString = translationProvider.getText("dynamic-channel.automation.all-days");
+ } else if (ScheduledEvents.WEEKDAYS.equals(days)) {
+ daysString = translationProvider.getText("dynamic-channel.automation.weekdays");
+ } else if (ScheduledEvents.WEEKENDS.equals(days)) {
+ daysString = translationProvider.getText("dynamic-channel.automation.weekends");
+ } else {
+ StringJoiner joiner = new StringJoiner(", ");
+ days.forEach(day -> joiner.add(day.getDisplayName(TextStyle.SHORT, translationProvider.getLocale())));
+ daysString = joiner.toString();
}
- if (isChannelListChanged) {
- updateThing(editThing().withChannels(allChannels).build());
+ return translationProvider.getText("dynamic-channel.automation-enabled.label", sceneName, timeString,
+ daysString);
+ }
+
+ private String getFormattedTimeOffset(int minutes) {
+ if (minutes >= 60) {
+ int remainder = minutes % 60;
+ if (remainder == 0) {
+ return translationProvider.getText("dynamic-channel.automation.hour", minutes / 60);
+ }
+ return translationProvider.getText("dynamic-channel.automation.hour-minute", minutes / 60, remainder);
+ }
+ return translationProvider.getText("dynamic-channel.automation.minute", minutes);
+ }
+
+ private void updateScheduledEventStates(List<ScheduledEvent> scheduledEvents) {
+ ChannelGroupUID channelGroupUid = new ChannelGroupUID(thing.getUID(),
+ HDPowerViewBindingConstants.CHANNEL_GROUP_AUTOMATIONS);
+ for (ScheduledEvent scheduledEvent : scheduledEvents) {
+ String scheduledEventId = Integer.toString(scheduledEvent.id);
+ ChannelUID channelUid = new ChannelUID(channelGroupUid, scheduledEventId);
+ updateState(channelUid, scheduledEvent.enabled ? OnOffType.ON : OnOffType.OFF);
}
}
return ret;
}
- private Map<String, Channel> getIdSceneChannelMap() {
- Map<String, Channel> ret = new HashMap<>();
- for (Channel channel : getThing().getChannels()) {
- if (sceneChannelTypeUID.equals(channel.getChannelTypeUID())) {
- ret.put(channel.getUID().getId(), channel);
- }
- }
- return ret;
- }
-
- private Map<String, Channel> getIdSceneCollectionChannelMap() {
- Map<String, Channel> 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<Thing, String> thingIdMap = getThingIdMap();
for (Entry<Thing, String> item : thingIdMap.entrySet()) {