Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[hue] Add support for enabling automations (API v2) #16980

Merged
merged 21 commits into from
Aug 19, 2024
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions bundles/org.openhab.binding.hue/doc/readme_v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.type.ChannelTypeUID;

/**
* The {@link HueBindingConstants} class defines common constants, which are
Expand Down Expand Up @@ -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");
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@

import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonPrimitive;
import com.google.gson.annotations.SerializedName;

/**
Expand Down Expand Up @@ -107,7 +108,7 @@ 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;

/**
* Constructor
Expand Down Expand Up @@ -661,13 +662,14 @@ public State getSceneState() {

/**
* Check if the smart scene resource contains a 'state' element. If such an element is present, returns a Boolean
* Optional whose value depends on the value of that element, or an empty Optional if it is not.
* Optional whose value depends on the value of that element, or an empty Optional if it is not. Note that in some
* resource types the 'state' element is not a String primitive.
*
* @return true, false, or empty.
*/
public Optional<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));
}
Expand Down Expand Up @@ -927,4 +929,8 @@ public String toString() {
return String.format("id:%s, type:%s", Objects.nonNull(id) ? id : "?" + " ".repeat(35),
getType().name().toLowerCase());
}

public boolean isStateNull() {
return Objects.isNull(state);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import java.io.IOException;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
Expand All @@ -27,6 +28,8 @@
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
Expand All @@ -50,7 +53,11 @@
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.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.ThingRegistry;
Expand All @@ -62,8 +69,11 @@
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.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
import org.osgi.framework.Bundle;
import org.osgi.framework.FrameworkUtil;
import org.osgi.framework.ServiceRegistration;
Expand Down Expand Up @@ -93,6 +103,10 @@ 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 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.
Expand All @@ -107,11 +121,14 @@ public class Clip2BridgeHandler extends BaseBridgeHandler {
private final Bundle bundle;
private final LocaleProvider localeProvider;
private final TranslationProvider translationProvider;
private final Set<String> automationIds = new HashSet<>();;
private final ChannelGroupUID automationChannelGroupUID;

private @Nullable Clip2Bridge clip2Bridge;
private @Nullable ServiceRegistration<?> trustManagerRegistration;
private @Nullable Clip2ThingDiscoveryService discoveryService;

private @Nullable Future<?> channelRefreshTask;
private @Nullable Future<?> checkConnectionTask;
private @Nullable Future<?> updateOnlineStateTask;
private @Nullable ScheduledFuture<?> scheduledUpdateTask;
Expand All @@ -129,6 +146,7 @@ public Clip2BridgeHandler(Bridge bridge, HttpClientFactory httpClientFactory, Th
this.bundle = FrameworkUtil.getBundle(getClass());
this.localeProvider = localeProvider;
this.translationProvider = translationProvider;
this.automationChannelGroupUID = new ChannelGroupUID(thing.getUID(), CHANNEL_GROUP_AUTOMATION);
}

/**
Expand Down Expand Up @@ -265,9 +283,11 @@ private void disposeAssets() {
logger.debug("disposeAssets() {}", this);
synchronized (this) {
assetsLoaded = false;
cancelTask(channelRefreshTask, true);
cancelTask(checkConnectionTask, true);
cancelTask(updateOnlineStateTask, true);
cancelTask(scheduledUpdateTask, true);
channelRefreshTask = null;
checkConnectionTask = null;
updateOnlineStateTask = null;
scheduledUpdateTask = null;
Expand Down Expand Up @@ -418,10 +438,27 @@ public Collection<Class<? extends ThingHandlerService>> getServices() {

@Override
public void handleCommand(ChannelUID channelUID, Command command) {
if (RefreshType.REFRESH.equals(command)) {
return;
if (CHANNEL_GROUP_AUTOMATION.equals(channelUID.getGroupId())) {
try {
Resources resources;
if (RefreshType.REFRESH.equals(command)) {
resources = getClip2Bridge().getResources(new ResourceReference()
.setType(ResourceType.BEHAVIOR_INSTANCE).setId(channelUID.getIdWithoutGroup()));
onResources(resources.getResources());
} else {
resources = getClip2Bridge().putResource(new Resource(ResourceType.BEHAVIOR_INSTANCE)
.setId(channelUID.getIdWithoutGroup()).setEnabled(command));
}
if (resources.hasErrors()) {
logger.info("handleCommand({}, {}) succeeded with errors: {}", channelUID, command,
andrewfg marked this conversation as resolved.
Show resolved Hide resolved
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
Expand Down Expand Up @@ -527,6 +564,7 @@ public void onResourcesEvent(List<Resource> resources) {
}

private void onResourcesEventTask(List<Resource> resources) {
onResources(resources);
int numberOfResources = resources.size();
logger.debug("onResourcesEventTask() resource count {}", numberOfResources);
Setters.mergeLightResources(resources);
Expand Down Expand Up @@ -752,6 +790,8 @@ private void updateThingsNow() {
}
});
}

updateChannels();
} catch (ApiException | AssetNotLoadedException e) {
if (logger.isDebugEnabled()) {
logger.debug("updateThingsNow() unexpected exception", e);
Expand All @@ -775,4 +815,84 @@ private void updateThingsScheduled(int delayMilliSeconds) {
scheduledUpdateTask = scheduler.schedule(() -> updateThingsNow(), delayMilliSeconds, TimeUnit.MILLISECONDS);
}
}

/**
* Create the automation channels
*/
private void updateChannels() {
List<Resource> resources;
try {
resources = getClip2Bridge().getResources(BEHAVIOR).getResources();
} catch (ApiException | AssetNotLoadedException e) {
if (logger.isDebugEnabled()) {
logger.debug("updateChannels() unexpected exception", e);
} else {
logger.warn("Unexpected exception '{}' while updating channels.", e.getMessage());
}
return;
} catch (InterruptedException e) {
return;
}

List<Resource> automations = getAutomationsList(resources);

if (automations.size() != automationIds.size()
|| automations.stream().anyMatch(a -> !automationIds.contains(a.getId()))) {
automationIds.clear();
automationIds.addAll(automations.stream().map(a -> a.getId()).collect(Collectors.toSet()));

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());
}
}

/**
* 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 and update the automation channels
*/
public void onResources(List<Resource> resources) {
boolean refreshAutomationChannels = false;
for (Resource automation : getAutomationsList(resources)) {
String automationId = automation.getId();
refreshAutomationChannels |= !automationIds.contains(automationId);
ChannelUID channelUID = new ChannelUID(automationChannelGroupUID, automationId);
Boolean enabled = automation.getEnabled();
State state = Objects.nonNull(enabled) ? OnOffType.from(enabled) : UnDefType.UNDEF;
updateState(channelUID, state);
}
if (refreshAutomationChannels) {
channelRefreshTask = scheduler.submit(() -> updateChannels());
}
}

/**
* Create a filtered resource list that contains only automation resources
*/
private List<Resource> getAutomationsList(Collection<Resource> resources) {
return resources.stream().filter(r -> (ResourceType.BEHAVIOR_INSTANCE == r.getType()) && r.isStateNull())
.toList();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@
<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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -286,4 +286,14 @@
<category>Siren</category>
</channel-type>

<channel-type id="automation-enable">
<item-type>Switch</item-type>
<label>Enable</label>
andrewfg marked this conversation as resolved.
Show resolved Hide resolved
<category>Switch</category>
</channel-type>

<channel-group-type id="automation">
<label>Automations</label>
</channel-group-type>

</thing:thing-descriptions>
Original file line number Diff line number Diff line change
Expand Up @@ -905,4 +905,14 @@ void testTimedEffectSetter() {
assertTrue(resultEffect instanceof TimedEffects);
assertEquals(Duration.ofMillis(44), ((TimedEffects) resultEffect).getDuration());
}

@Test
void testBehaviorInstance() {
String json = load(ResourceType.BEHAVIOR_INSTANCE.name().toLowerCase());
Resources resources = GSON.fromJson(json, Resources.class);
assertNotNull(resources);
List<Resource> list = resources.getResources();
assertNotNull(list);
assertEquals(2, list.size());
}
}
Loading