Skip to content

Commit

Permalink
Detect scenes being activated and temporarily disable turn-on events (#…
Browse files Browse the repository at this point in the history
…12)

For affected lights and groups.
  • Loading branch information
stefanvictora authored Jun 28, 2024
1 parent 2702aeb commit 44f0579
Show file tree
Hide file tree
Showing 23 changed files with 966 additions and 68 deletions.
18 changes: 12 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
# Changelog

## [Unreleased]
## [0.11.0] - 2024-06-29

### Added
- **Scene Activation Detection**: Hue Scheduler now temporarily disables turn-on event tracking for affected lights and groups when scenes are activated. This prevents it from taking over control when lights are turned on via scenes (#10).
- A new `--scene-activation-ignore-window` command line option has been added to fine-tune this behavior, with a default value of `5` seconds.
- To disable this behavior, you can either disable user modification tracking entirely (see `--disable-user-modification-tracking`) or set the `force:true` property on a state-by-state basis.
- Limitations: When connected to Home Assistant, scenes turned on via the Hue bridge (e.g., via apps, smart switches, etc.) cannot be detected due to Home Assistant limitations. However, Hue-based scenes turned on via Home Assistant can still be detected.

### Changed
- **Improved color accuracy**: Improved algorithm for RGB to XY as well as XY to CT conversion
- **Improved color comparison**: Manual overrides for color and brightness are now detected using similarity thresholds instead of exact comparisons
- **Color Accuracy**: Enhanced algorithms for RGB to XY and XY to CT conversions.
- **Color Comparison**: Implemented similarity thresholds for detecting manual overrides in color and brightness, replacing exact matches.

### Fixed
- Fixed support for `effect:colorloop` for Home Assistant. More effects will be added in upcoming releases.
- **Home Assistant**: Resolved an issue with using `effect:colorloop`. More effects will be added in upcoming releases.

### Removed
- Removed support for setting `hue` and `sat` properties independently: This was only supported by Hue API v1.
- **Hue and Saturation**: Removed support for setting `hue` and `sat` properties independently, as this feature was only available in Hue API v1.

## [0.10.0] - 2024-05-30

Expand Down
9 changes: 9 additions & 0 deletions docs/advanced_command_line_options.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ Flag to globally disable tracking of user modifications of lights. Per default H

**Default**: false

### `--scene-activation-ignore-window`

Only relevant and active if user modification tracking is not disabled (see `--disable-user-modification-tracking`).
Defines the delay in seconds during which turn-on events for affected lights and groups are ignored
after a scene activation has been detected. This prevents Hue Scheduler from taking over after lights or groups have
been turned on via a scene.

**Default**: `5` seconds

### `--default-interpolation-transition-time`

Flag to configure the default transition time used for the interpolated call when turning a light on during a `tr-before` transition. Defined either as a multiple of 100ms or with the already mentioned shorthands e.g. `5s`. If the previous state already contains a ``tr`` property, Hue Scheduler reuses the custom value instead.
Expand Down
5 changes: 5 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.1.8</version>
</dependency>

<dependency>
<groupId>org.projectlombok</groupId>
Expand Down
43 changes: 35 additions & 8 deletions src/main/java/at/sv/hue/HueScheduler.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
import at.sv.hue.api.ManualOverrideTrackerImpl;
import at.sv.hue.api.PutCall;
import at.sv.hue.api.RateLimiter;
import at.sv.hue.api.SceneEventListener;
import at.sv.hue.api.SceneEventListenerImpl;
import at.sv.hue.api.hass.HassApiImpl;
import at.sv.hue.api.hass.HassApiUtils;
import at.sv.hue.api.hass.HassEventHandler;
Expand All @@ -23,6 +25,7 @@
import at.sv.hue.time.StartTimeProvider;
import at.sv.hue.time.StartTimeProviderImpl;
import at.sv.hue.time.SunTimesProviderImpl;
import com.github.benmanes.caffeine.cache.Ticker;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import org.slf4j.Logger;
Expand Down Expand Up @@ -152,12 +155,18 @@ public final class HueScheduler implements Runnable {
"tr-before states. Default: ${DEFAULT-VALUE} minutes."
)
int minTrBeforeGapInMinutes;
@Option(names = "--scene-activation-ignore-window", paramLabel = "<duration>",
defaultValue = "${env:SCENE_ACTIVATION_IGNORE_WINDOW:-5}",
description = "The delay in seconds during which turn-on events for affected lights and groups are ignored " +
"after a scene activation has been detected. Default: ${DEFAULT-VALUE} seconds.")
int sceneActivationIgnoreWindowInSeconds;
private HueApi api;
private StateScheduler stateScheduler;
private final ManualOverrideTracker manualOverrideTracker;
private final LightEventListener lightEventListener;
private StartTimeProvider startTimeProvider;
private Supplier<ZonedDateTime> currentTime;
private SceneEventListenerImpl sceneEventListener;

public HueScheduler() {
lightStates = new HashMap<>();
Expand All @@ -170,9 +179,12 @@ public HueScheduler(HueApi api, StateScheduler stateScheduler,
double requestsPerSecond, boolean controlGroupLightsIndividually,
boolean disableUserModificationTracking, String defaultInterpolationTransitionTimeString,
int powerOnRescheduleDelayInMs, int bridgeFailureRetryDelayInSeconds, int multiColorAdjustmentDelay,
int minTrBeforeGapInMinutes, boolean interpolateAll) {
int minTrBeforeGapInMinutes, int sceneActivationIgnoreWindowInSeconds, boolean interpolateAll) {
this();
this.api = api;
ZonedDateTime initialTime = currentTime.get();
this.sceneEventListener = new SceneEventListenerImpl(api,
() -> Duration.between(initialTime, currentTime.get()).toNanos(), sceneActivationIgnoreWindowInSeconds);
this.stateScheduler = stateScheduler;
this.startTimeProvider = startTimeProvider;
this.currentTime = currentTime;
Expand All @@ -184,6 +196,7 @@ public HueScheduler(HueApi api, StateScheduler stateScheduler,
this.bridgeFailureRetryDelayInSeconds = bridgeFailureRetryDelayInSeconds;
this.multiColorAdjustmentDelay = multiColorAdjustmentDelay;
this.minTrBeforeGapInMinutes = minTrBeforeGapInMinutes;
this.sceneActivationIgnoreWindowInSeconds = sceneActivationIgnoreWindowInSeconds;
defaultInterpolationTransitionTime = parseInterpolationTransitionTime(defaultInterpolationTransitionTimeString);
this.interpolateAll = interpolateAll;
apiCacheInvalidationIntervalInMinutes = 15;
Expand Down Expand Up @@ -229,15 +242,18 @@ private void setupHassApi() {
.build();
RateLimiter rateLimiter = RateLimiter.create(requestsPerSecond);
api = new HassApiImpl(apiHost, new HttpResourceProviderImpl(httpClient), rateLimiter);
sceneEventListener = new SceneEventListenerImpl(api, Ticker.systemTicker(), sceneActivationIgnoreWindowInSeconds);
new HassEventStreamReader(HassApiUtils.getHassWebsocketOrigin(apiHost), accessToken, httpClient,
new HassEventHandler(lightEventListener)).start();
new HassEventHandler(lightEventListener, sceneEventListener)).start();
}

private void setupHueApi() {
OkHttpClient httpsClient = createHueHttpsClient();
RateLimiter rateLimiter = RateLimiter.create(requestsPerSecond);
api = new HueApiImpl(new HttpResourceProviderImpl(httpsClient), apiHost, accessToken, rateLimiter);
new HueEventStreamReader(apiHost, accessToken, httpsClient, new HueEventHandler(lightEventListener), eventStreamReadTimeoutInMinutes).start();
sceneEventListener = new SceneEventListenerImpl(api, Ticker.systemTicker(), sceneActivationIgnoreWindowInSeconds);
new HueEventStreamReader(apiHost, accessToken, httpsClient, new HueEventHandler(lightEventListener, sceneEventListener),
eventStreamReadTimeoutInMinutes).start();
}

private void createAndStart() {
Expand Down Expand Up @@ -430,8 +446,8 @@ private void schedule(ScheduledState state, long delayInMs) {
return;
}
try {
if (stateIsNotEnforced(state) && stateHasBeenManuallyOverriddenSinceLastSeen(state)) {
LOG.info("Manually overridden: Pause updates until turned off and on again");
if (shouldTrackUserModification(state) && (turnedOnThroughScene(state) || stateHasBeenManuallyOverriddenSinceLastSeen(state))) {
LOG.info("Manually overridden or scene turn-on: Pause updates until turned off and on again");
manualOverrideTracker.onManuallyOverridden(state.getId());
retryWhenBackOn(state);
return;
Expand Down Expand Up @@ -492,11 +508,18 @@ private boolean isGroupOrLightOff(ScheduledState state) {
return off;
}

private boolean stateIsNotEnforced(ScheduledState state) {
return !disableUserModificationTracking && !state.isForced() && !manualOverrideTracker.shouldEnforceSchedule(state.getId());
private boolean shouldTrackUserModification(ScheduledState state) {
return !disableUserModificationTracking && !state.isForced();
}

private boolean turnedOnThroughScene(ScheduledState state) {
return manualOverrideTracker.wasJustTurnedOn(state.getId()) && sceneEventListener.wasRecentlyAffectedByAScene(state.getId());
}

private boolean stateHasBeenManuallyOverriddenSinceLastSeen(ScheduledState scheduledState) {
if (manualOverrideTracker.wasJustTurnedOn(scheduledState.getId())) {
return false;
}
ScheduledState lastSeenState = getLastSeenState(scheduledState);
if (lastSeenState == null) {
return false;
Expand Down Expand Up @@ -527,7 +550,7 @@ private void putAdditionalInterpolatedStateIfNeeded(ScheduledState state) {
ScheduledState previousState = previousStateSnapshot.getScheduledState();
ScheduledState lastSeenState = getLastSeenState(state);
if ((lastSeenState == previousState || state.isSameState(lastSeenState) && state.isSplitState())
&& !manualOverrideTracker.shouldEnforceSchedule(state.getId())) {
&& !manualOverrideTracker.wasJustTurnedOn(state.getId())) {
return; // skip interpolations if the previous or current state was the last state set without any power cycles
}
PutCall interpolatedPutCall = state.getInterpolatedPutCall(previousStateSnapshot, currentTime.get(), true);
Expand Down Expand Up @@ -752,6 +775,10 @@ LightEventListener getHueEventListener() {
return lightEventListener;
}

SceneEventListener getSceneEventListener() {
return sceneEventListener;
}

ManualOverrideTracker getManualOverrideTracker() {
return manualOverrideTracker;
}
Expand Down
6 changes: 6 additions & 0 deletions src/main/java/at/sv/hue/api/HueApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ public interface HueApi {
*/
List<String> getGroupLights(String groupId);

/**
* @return the lights and group id related to the given scene. If not found, empty list. Not null.
* @throws ApiFailure if the api call failed
*/
List<String> getAffectedIdsByScene(String sceneId);

/**
* @return a list of group ids the light is assigned to, not null
*/
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/at/sv/hue/api/ManualOverrideTracker.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public interface ManualOverrideTracker {

boolean isOff(String id);

boolean shouldEnforceSchedule(String id);
boolean wasJustTurnedOn(String id);

void onAutomaticallyAssigned(String id);
}
10 changes: 5 additions & 5 deletions src/main/java/at/sv/hue/api/ManualOverrideTrackerImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public void onLightTurnedOn(String id) {
TrackedState trackedState = getOrCreateTrackedState(id);
trackedState.setManuallyOverridden(false);
trackedState.setLightIsOff(false);
trackedState.setEnforceSchedule(true);
trackedState.setJustTurnedOn(true);
}

@Override
Expand All @@ -44,15 +44,15 @@ public boolean isOff(String id) {
}

@Override
public boolean shouldEnforceSchedule(String id) {
return getOrDefaultState(id).isEnforceSchedule();
public boolean wasJustTurnedOn(String id) {
return getOrDefaultState(id).isJustTurnedOn();
}

@Override
public void onAutomaticallyAssigned(String id) {
TrackedState trackedState = getOrCreateTrackedState(id);
trackedState.setManuallyOverridden(false); // maybe not needed, as this flag is overridden also on light-on events
trackedState.setEnforceSchedule(false);
trackedState.setJustTurnedOn(false);
}

private TrackedState getOrCreateTrackedState(String id) {
Expand All @@ -67,7 +67,7 @@ private TrackedState getOrDefaultState(String id) {
@Setter
private static final class TrackedState {
private boolean manuallyOverridden;
private boolean enforceSchedule;
private boolean justTurnedOn;
private boolean lightIsOff;
}
}
7 changes: 7 additions & 0 deletions src/main/java/at/sv/hue/api/SceneEventListener.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package at.sv.hue.api;

public interface SceneEventListener {
void onSceneActivated(String id);

boolean wasRecentlyAffectedByAScene(String id);
}
41 changes: 41 additions & 0 deletions src/main/java/at/sv/hue/api/SceneEventListenerImpl.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package at.sv.hue.api;

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.Ticker;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;

import java.time.Duration;
import java.util.List;

@Slf4j
public final class SceneEventListenerImpl implements SceneEventListener {

private final HueApi hueApi;
private final Cache<String, String> recentlyAffectedIds;

public SceneEventListenerImpl(HueApi hueApi, Ticker ticker, int ignoreWindowInSeconds) {
this.hueApi = hueApi;
recentlyAffectedIds = Caffeine.newBuilder()
.ticker(ticker)
.expireAfterWrite(Duration.ofSeconds(ignoreWindowInSeconds))
.build();
}

@Override
public void onSceneActivated(String id) {
MDC.put("context", "on-event " + id);
List<String> sceneLights = hueApi.getAffectedIdsByScene(id);
sceneLights.forEach(lightId -> recentlyAffectedIds.put(lightId, lightId));
sceneLights.stream()
.flatMap(light -> hueApi.getAssignedGroups(light).stream())
.distinct()
.forEach(groupId -> recentlyAffectedIds.put(groupId, groupId));
}

@Override
public boolean wasRecentlyAffectedByAScene(String id) {
return recentlyAffectedIds.getIfPresent(id) != null;
}
}
26 changes: 24 additions & 2 deletions src/main/java/at/sv/hue/api/hass/HassApiImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ public List<LightState> getGroupStates(String id) {
return groupLights.stream()
.map(currentStates::get)
.filter(Objects::nonNull)
.filter(HassApiImpl::isSupportedStateType)
.map(this::createLightState)
.collect(Collectors.toList());
}
Expand Down Expand Up @@ -159,6 +160,28 @@ public List<String> getGroupLights(String groupId) {
return groupLights;
}

@Override
public List<String> getAffectedIdsByScene(String sceneId) {
State scene = getOrLookupStates().get(sceneId);
if (scene == null) {
return List.of();
}
List<String> affectedIds = new ArrayList<>();
StateAttributes sceneAttributes = scene.getAttributes();
if (sceneAttributes.getEntity_id() != null) { // HA scene
affectedIds.addAll(sceneAttributes.getEntity_id());
}
if (sceneAttributes.getGroup_name() != null) { // Hue scene
try {
String groupId = getGroupId(sceneAttributes.getGroup_name());
affectedIds.add(groupId);
affectedIds.addAll(getGroupLights(groupId));
} catch (Exception ignore) {
}
}
return affectedIds;
}

@Override
public List<String> getAssignedGroups(String lightId) {
String lightName = getLightName(lightId);
Expand Down Expand Up @@ -260,7 +283,6 @@ private Map<String, State> lookupStates() {
List<State> states = mapper.readValue(response, new TypeReference<>() {
});
return states.stream()
.filter(HassApiImpl::isSupportedStateType)
.collect(Collectors.toMap(State::getEntity_id, Function.identity()));
} catch (JsonProcessingException | NullPointerException e) {
throw new ApiFailure("Failed to parse light states response '" + response + ":" + e.getLocalizedMessage());
Expand Down Expand Up @@ -365,7 +387,7 @@ private static boolean isHueGroup(State state) {
}

private static boolean isHassGroup(State state) {
return state.attributes.entity_id != null;
return state.attributes.entity_id != null && !state.isScene();
}

private boolean containsLightIdOrName(State state, String lightId, String lightName) {
Expand Down
7 changes: 6 additions & 1 deletion src/main/java/at/sv/hue/api/hass/HassEventHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import at.sv.hue.api.BridgeAuthenticationFailure;
import at.sv.hue.api.LightEventListener;
import at.sv.hue.api.SceneEventListener;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
Expand All @@ -11,9 +12,11 @@ public final class HassEventHandler {

private final ObjectMapper objectMapper;
private final LightEventListener eventListener;
private final SceneEventListener sceneEventListener;

public HassEventHandler(LightEventListener eventListener) {
public HassEventHandler(LightEventListener eventListener, SceneEventListener sceneEventListener) {
this.eventListener = eventListener;
this.sceneEventListener = sceneEventListener;
objectMapper = new ObjectMapper();
objectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
}
Expand All @@ -40,6 +43,8 @@ private void handleStateChangedEvent(String entityId, State oldState, State newS
eventListener.onLightOn(entityId, true);
} else if (oldState.isOn() && (newState.isOff() || newState.isUnavailable())) {
eventListener.onLightOff(entityId);
} else if (newState.isScene() && !newState.isUnavailable()) {
sceneEventListener.onSceneActivated(entityId);
}
}

Expand Down
4 changes: 4 additions & 0 deletions src/main/java/at/sv/hue/api/hass/State.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,8 @@ boolean isOff() {
boolean isUnavailable() {
return "unavailable".equals(state);
}

public boolean isScene() {
return entity_id.startsWith("scene.");
}
}
1 change: 1 addition & 0 deletions src/main/java/at/sv/hue/api/hass/StateAttributes.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
@Data
final class StateAttributes {
String friendly_name;
String group_name;
String color_mode;
Integer brightness;
Integer color_temp;
Expand Down
Loading

0 comments on commit 44f0579

Please sign in to comment.