]> git.basschouten.com Git - openhab-addons.git/commitdiff
[hue] Support timed effects (#15408)
authorAndrew Fiddian-Green <software@whitebear.ch>
Sat, 14 Oct 2023 17:30:15 +0000 (18:30 +0100)
committerGitHub <noreply@github.com>
Sat, 14 Oct 2023 17:30:15 +0000 (19:30 +0200)
Signed-off-by: Andrew Fiddian-Green <software@whitebear.ch>
bundles/org.openhab.binding.hue/doc/readme_v2.md
bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/HueBindingConstants.java
bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/dto/clip2/Resource.java
bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/dto/clip2/TimedEffects.java
bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/dto/clip2/helper/Setters.java
bundles/org.openhab.binding.hue/src/main/java/org/openhab/binding/hue/internal/handler/Clip2ThingHandler.java
bundles/org.openhab.binding.hue/src/test/java/org/openhab/binding/hue/internal/clip2/Clip2DtoTest.java

index 2bec43af0e1f3dc2323e3386423622640dc4fbbf..2d7b38780b5763c2c5984c157c5e2c8034245d2a 100644 (file)
@@ -87,7 +87,13 @@ Device things support some of the following channels:
 The exact list of channels in a given device is determined at run time when the system is started.
 Each device reports its own live list of capabilities, and the respective list of channels is created accordingly.
 
-The channels `color-xy-only`,  `dimming-only` and `on-off-only` are *advanced* channels - see [below](###advanced-channels-for-devices-,-rooms-and-zones) for more details.
+The channels `color-xy-only`, `dimming-only` and `on-off-only` are *advanced* channels - see [below](#advanced-channels-for-devices-rooms-and-zones) for more details.
+
+The `effect` channel is an amalgamation of 'normal' and 'timed' effects.
+To activate a 'normal' effect, the binding sends a single command to activate the respective effect.
+To activate a 'timed' effect, the binding sends a first command to set the timing followed a second command to activate the effect.
+You can explicitly send the timing command via the [dynamics channel](#the-dynamics-channel) before you send the effect command.
+Or otherwise the binding will send a default timing command of 15 minutes.
 
 The `button-last-event` channel is a trigger channel.
 When the button is pressed the channel receives a number as calculated by the following formula:
@@ -140,6 +146,7 @@ When you set a value for the `dynamics` channel (e.g. 2000 milliseconds) and the
 When the `dynamics` channel value is changed, it triggers a time window of ten seconds during which the value is active.
 If the second command is sent within the active time window, it will be executed gradually according to the `dynamics` channel value.
 However, if the second command is sent after the active time window has expired, then it will be executed immediately.
+If the second command is a 'timed' effect, then the dynamics duration will be applied to that effect.
 
 ### Advanced Channels for Devices, Rooms and Zones
 
index 11ad57e7256b930266f5b92a5bcf1cd3ef0052cd..83c6ac420d1c35aeea75578370b9eebe27adfc74 100644 (file)
@@ -167,7 +167,7 @@ public class HueBindingConstants {
 
     // channel IDs that (optionally) support dynamics
     public static final Set<String> DYNAMIC_CHANNELS = Set.of(CHANNEL_2_BRIGHTNESS, CHANNEL_2_COLOR,
-            CHANNEL_2_COLOR_TEMP_PERCENT, CHANNEL_2_COLOR_TEMP_ABSOLUTE, CHANNEL_2_SCENE);
+            CHANNEL_2_COLOR_TEMP_PERCENT, CHANNEL_2_COLOR_TEMP_ABSOLUTE, CHANNEL_2_SCENE, CHANNEL_2_EFFECT);
 
     /*
      * Map of API v1 channel IDs against API v2 channel IDs where, if the v1 channel exists in the system, then we
index c9e21460883c4a477411c20975d8f2ebcf5defba..02330cd20d9be52cc0b14327297f4cc578bd547d 100644 (file)
@@ -24,6 +24,7 @@ import java.util.Optional;
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.eclipse.jdt.annotation.Nullable;
 import org.openhab.binding.hue.internal.dto.clip2.enums.ActionType;
+import org.openhab.binding.hue.internal.dto.clip2.enums.EffectType;
 import org.openhab.binding.hue.internal.dto.clip2.enums.RecallAction;
 import org.openhab.binding.hue.internal.dto.clip2.enums.ResourceType;
 import org.openhab.binding.hue.internal.dto.clip2.enums.ZigbeeStatus;
@@ -320,13 +321,33 @@ public class Resource {
         return UnDefType.NULL;
     }
 
-    public @Nullable Effects getEffects() {
+    public @Nullable Effects getFixedEffects() {
         return effects;
     }
 
+    /**
+     * Get the amalgamated effect state. The result may be either from an 'effects' field or from a 'timedEffects'
+     * field. If both fields are missing it returns UnDefType.NULL, otherwise if either field is present and has an
+     * active value (other than EffectType.NO_EFFECT) it returns a StringType of the name of the respective active
+     * effect; and if none of the above apply, it returns a StringType of 'NO_EFFECT'.
+     *
+     * @return either a StringType value or UnDefType.NULL
+     */
     public State getEffectState() {
         Effects effects = this.effects;
-        return Objects.nonNull(effects) ? new StringType(effects.getStatus().name()) : UnDefType.NULL;
+        TimedEffects timedEffects = this.timedEffects;
+        if (Objects.isNull(effects) && Objects.isNull(timedEffects)) {
+            return UnDefType.NULL;
+        }
+        EffectType effect = Objects.nonNull(effects) ? effects.getStatus() : null;
+        if (Objects.nonNull(effect) && effect != EffectType.NO_EFFECT) {
+            return new StringType(effect.name());
+        }
+        EffectType timedEffect = Objects.nonNull(timedEffects) ? timedEffects.getStatus() : null;
+        if (Objects.nonNull(timedEffect) && timedEffect != EffectType.NO_EFFECT) {
+            return new StringType(timedEffect.name());
+        }
+        return new StringType(EffectType.NO_EFFECT.name());
     }
 
     public @Nullable Boolean getEnabled() {
@@ -517,7 +538,7 @@ public class Resource {
         return Objects.nonNull(temperature) ? temperature.getTemperatureValidState() : UnDefType.NULL;
     }
 
-    public @Nullable Effects getTimedEffects() {
+    public @Nullable TimedEffects getTimedEffects() {
         return timedEffects;
     }
 
@@ -577,7 +598,7 @@ public class Resource {
         return this;
     }
 
-    public Resource setEffects(Effects effect) {
+    public Resource setFixedEffects(Effects effect) {
         this.effects = effect;
         return this;
     }
@@ -640,6 +661,19 @@ public class Resource {
         return this;
     }
 
+    public Resource setTimedEffects(TimedEffects timedEffects) {
+        this.timedEffects = timedEffects;
+        return this;
+    }
+
+    public Resource setTimedEffectsDuration(Duration dynamicsDuration) {
+        TimedEffects timedEffects = this.timedEffects;
+        if (Objects.nonNull(timedEffects)) {
+            timedEffects.setDuration(dynamicsDuration);
+        }
+        return this;
+    }
+
     public Resource setType(ResourceType resourceType) {
         this.type = resourceType.name().toLowerCase();
         return this;
index 2591d9f1e870d6e5121f5bf10106cbfc35efc829..e59dc68f470de77123b117f6115888a6df302581 100644 (file)
@@ -25,11 +25,13 @@ import org.eclipse.jdt.annotation.Nullable;
  */
 @NonNullByDefault
 public class TimedEffects extends Effects {
+    public static final Duration DEFAULT_DURATION = Duration.ofMinutes(15);
+
     private @Nullable Long duration;
 
     public @Nullable Duration getDuration() {
         Long duration = this.duration;
-        return Objects.nonNull(duration) ? Duration.ofMillis(duration) : Duration.ZERO;
+        return Objects.nonNull(duration) ? Duration.ofMillis(duration) : null;
     }
 
     public TimedEffects setDuration(Duration duration) {
index 17dc1b6d2b8a627247af0de729d104f8037c0bfe..df400725a316598125969b3533e332427e8a1aa5 100644 (file)
@@ -13,6 +13,7 @@
 package org.openhab.binding.hue.internal.dto.clip2.helper;
 
 import java.math.BigDecimal;
+import java.time.Duration;
 import java.util.List;
 import java.util.Objects;
 
@@ -29,6 +30,7 @@ import org.openhab.binding.hue.internal.dto.clip2.MetaData;
 import org.openhab.binding.hue.internal.dto.clip2.MirekSchema;
 import org.openhab.binding.hue.internal.dto.clip2.OnState;
 import org.openhab.binding.hue.internal.dto.clip2.Resource;
+import org.openhab.binding.hue.internal.dto.clip2.TimedEffects;
 import org.openhab.binding.hue.internal.dto.clip2.enums.ActionType;
 import org.openhab.binding.hue.internal.dto.clip2.enums.EffectType;
 import org.openhab.core.library.types.DecimalType;
@@ -198,9 +200,9 @@ public class Setters {
     }
 
     /**
-     * Setter for Effect field:
-     * Use the given command value to set the target resource DTO value based on the attributes of the source resource
-     * (if any).
+     * Setter for fixed or timed effect field:
+     * Use the given command value to set the target fixed or timed effects resource DTO value based on the attributes
+     * of the source resource (if any).
      *
      * @param target the target resource.
      * @param command the new state command should be a StringType.
@@ -210,12 +212,16 @@ public class Setters {
      */
     public static Resource setEffect(Resource target, Command command, @Nullable Resource source) {
         if ((command instanceof StringType) && Objects.nonNull(source)) {
-            Effects otherEffects = source.getEffects();
-            if (Objects.nonNull(otherEffects)) {
-                EffectType effectType = EffectType.of(((StringType) command).toString());
-                if (otherEffects.allows(effectType)) {
-                    target.setEffects(new Effects().setEffect(effectType));
-                }
+            EffectType commandEffectType = EffectType.of(((StringType) command).toString());
+            Effects sourceFixedEffects = source.getFixedEffects();
+            if (Objects.nonNull(sourceFixedEffects) && sourceFixedEffects.allows(commandEffectType)) {
+                target.setFixedEffects(new Effects().setEffect(commandEffectType));
+            }
+            TimedEffects sourceTimedEffects = source.getTimedEffects();
+            if (Objects.nonNull(sourceTimedEffects) && sourceTimedEffects.allows(commandEffectType)) {
+                Duration duration = sourceTimedEffects.getDuration();
+                target.setTimedEffects(((TimedEffects) new TimedEffects().setEffect(commandEffectType))
+                        .setDuration(Objects.nonNull(duration) ? duration : TimedEffects.DEFAULT_DURATION));
             }
         }
         return target;
@@ -239,6 +245,7 @@ public class Setters {
         if (Objects.isNull(targetOnOff) && Objects.nonNull(sourceOnOff)) {
             target.setOnState(sourceOnOff);
         }
+
         // dimming
         Dimming targetDimming = target.getDimming();
         Dimming sourceDimming = source.getDimming();
@@ -246,13 +253,15 @@ public class Setters {
             target.setDimming(sourceDimming);
             targetDimming = target.getDimming();
         }
+
         // minimum dimming level
-        Double targetMinDimmingLevel = Objects.nonNull(targetDimming) ? targetDimming.getMinimumDimmingLevel() : null;
-        Double sourceMinDimmingLevel = Objects.nonNull(sourceDimming) ? sourceDimming.getMinimumDimmingLevel() : null;
-        if (Objects.isNull(targetMinDimmingLevel) && Objects.nonNull(sourceMinDimmingLevel)) {
-            targetDimming = Objects.nonNull(targetDimming) ? targetDimming : new Dimming();
-            targetDimming.setMinimumDimmingLevel(sourceMinDimmingLevel);
+        if (Objects.nonNull(targetDimming)) {
+            Double sourceMinDimLevel = Objects.isNull(sourceDimming) ? null : sourceDimming.getMinimumDimmingLevel();
+            if (Objects.nonNull(sourceMinDimLevel)) {
+                targetDimming.setMinimumDimmingLevel(sourceMinDimLevel);
+            }
         }
+
         // color
         ColorXy targetColor = target.getColorXy();
         ColorXy sourceColor = source.getColorXy();
@@ -260,13 +269,13 @@ public class Setters {
             target.setColorXy(sourceColor);
             targetColor = target.getColorXy();
         }
+
         // color gamut
-        Gamut targetGamut = Objects.nonNull(targetColor) ? targetColor.getGamut() : null;
-        Gamut sourceGamut = Objects.nonNull(sourceColor) ? sourceColor.getGamut() : null;
-        if (Objects.isNull(targetGamut) && Objects.nonNull(sourceGamut)) {
-            targetColor = Objects.nonNull(targetColor) ? targetColor : new ColorXy();
+        Gamut sourceGamut = Objects.isNull(sourceColor) ? null : sourceColor.getGamut();
+        if (Objects.nonNull(targetColor) && Objects.nonNull(sourceGamut)) {
             targetColor.setGamut(sourceGamut);
         }
+
         // color temperature
         ColorTemperature targetColorTemp = target.getColorTemperature();
         ColorTemperature sourceColorTemp = source.getColorTemperature();
@@ -274,40 +283,65 @@ public class Setters {
             target.setColorTemperature(sourceColorTemp);
             targetColorTemp = target.getColorTemperature();
         }
+
         // mirek schema
-        MirekSchema targetMirekSchema = Objects.nonNull(targetColorTemp) ? targetColorTemp.getMirekSchema() : null;
-        MirekSchema sourceMirekSchema = Objects.nonNull(sourceColorTemp) ? sourceColorTemp.getMirekSchema() : null;
-        if (Objects.isNull(targetMirekSchema) && Objects.nonNull(sourceMirekSchema)) {
-            targetColorTemp = Objects.nonNull(targetColorTemp) ? targetColorTemp : new ColorTemperature();
-            targetColorTemp.setMirekSchema(sourceMirekSchema);
+        if (Objects.nonNull(targetColorTemp)) {
+            MirekSchema sourceMirekSchema = Objects.isNull(sourceColorTemp) ? null : sourceColorTemp.getMirekSchema();
+            if (Objects.nonNull(sourceMirekSchema)) {
+                targetColorTemp.setMirekSchema(sourceMirekSchema);
+            }
         }
+
         // metadata
         MetaData targetMetaData = target.getMetaData();
         MetaData sourceMetaData = source.getMetaData();
         if (Objects.isNull(targetMetaData) && Objects.nonNull(sourceMetaData)) {
             target.setMetadata(sourceMetaData);
         }
+
         // alerts
         Alerts targetAlerts = target.getAlerts();
         Alerts sourceAlerts = source.getAlerts();
         if (Objects.isNull(targetAlerts) && Objects.nonNull(sourceAlerts)) {
             target.setAlerts(sourceAlerts);
         }
-        // effects
-        Effects targetEffects = target.getEffects();
-        Effects sourceEffects = source.getEffects();
-        if (Objects.isNull(targetEffects) && Objects.nonNull(sourceEffects)) {
-            targetEffects = sourceEffects;
-            target.setEffects(sourceEffects);
-            targetEffects = target.getEffects();
+
+        // fixed effects
+        Effects targetFixedEffects = target.getFixedEffects();
+        Effects sourceFixedEffects = source.getFixedEffects();
+        if (Objects.isNull(targetFixedEffects) && Objects.nonNull(sourceFixedEffects)) {
+            target.setFixedEffects(sourceFixedEffects);
+            targetFixedEffects = target.getFixedEffects();
+        }
+
+        // fixed effects allowed values
+        if (Objects.nonNull(targetFixedEffects)) {
+            List<String> values = Objects.isNull(sourceFixedEffects) ? List.of() : sourceFixedEffects.getStatusValues();
+            if (!values.isEmpty()) {
+                targetFixedEffects.setStatusValues(values);
+            }
+        }
+
+        // timed effects
+        TimedEffects targetTimedEffects = target.getTimedEffects();
+        TimedEffects sourceTimedEffects = source.getTimedEffects();
+        if (Objects.isNull(targetTimedEffects) && Objects.nonNull(sourceTimedEffects)) {
+            target.setTimedEffects(sourceTimedEffects);
+            targetTimedEffects = target.getTimedEffects();
         }
-        // effects values
-        List<String> targetStatusValues = Objects.nonNull(targetEffects) ? targetEffects.getStatusValues() : null;
-        List<String> sourceStatusValues = Objects.nonNull(sourceEffects) ? sourceEffects.getStatusValues() : null;
-        if (Objects.isNull(targetStatusValues) && Objects.nonNull(sourceStatusValues)) {
-            targetEffects = Objects.nonNull(targetEffects) ? targetEffects : new Effects();
-            targetEffects.setStatusValues(sourceStatusValues);
+
+        // timed effects allowed values and duration
+        if (Objects.nonNull(targetTimedEffects)) {
+            List<String> values = Objects.isNull(sourceTimedEffects) ? List.of() : sourceTimedEffects.getStatusValues();
+            if (!values.isEmpty()) {
+                targetTimedEffects.setStatusValues(values);
+            }
+            Duration duration = Objects.isNull(sourceTimedEffects) ? null : sourceTimedEffects.getDuration();
+            if (Objects.nonNull(duration)) {
+                targetTimedEffects.setDuration(duration);
+            }
         }
+
         return target;
     }
 }
index 6a2e561363ab7c5b85e9a3b735fad52ffff98e34..93037557c26e492874b65ca6a9fd492a5e6bb919 100644 (file)
@@ -29,6 +29,7 @@ import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.Future;
 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;
@@ -45,6 +46,7 @@ import org.openhab.binding.hue.internal.dto.clip2.ProductData;
 import org.openhab.binding.hue.internal.dto.clip2.Resource;
 import org.openhab.binding.hue.internal.dto.clip2.ResourceReference;
 import org.openhab.binding.hue.internal.dto.clip2.Resources;
+import org.openhab.binding.hue.internal.dto.clip2.TimedEffects;
 import org.openhab.binding.hue.internal.dto.clip2.enums.ActionType;
 import org.openhab.binding.hue.internal.dto.clip2.enums.EffectType;
 import org.openhab.binding.hue.internal.dto.clip2.enums.RecallAction;
@@ -337,8 +339,7 @@ public class Clip2ThingHandler extends BaseThingHandler {
                 break;
 
             case CHANNEL_2_EFFECT:
-                putResource = Setters.setEffect(new Resource(lightResourceType), command, cache);
-                putResource.setOnOff(OnOffType.ON);
+                putResource = Setters.setEffect(new Resource(lightResourceType), command, cache).setOnOff(OnOffType.ON);
                 break;
 
             case CHANNEL_2_COLOR_TEMP_PERCENT:
@@ -487,6 +488,8 @@ public class Clip2ThingHandler extends BaseThingHandler {
                     && !dynamicsDuration.isNegative()) {
                 if (ResourceType.SCENE == putResource.getType()) {
                     putResource.setRecallDuration(dynamicsDuration);
+                } else if (CHANNEL_2_EFFECT == channelId) {
+                    putResource.setTimedEffectsDuration(dynamicsDuration);
                 } else {
                     putResource.setDynamicsDuration(dynamicsDuration);
                 }
@@ -945,21 +948,23 @@ public class Clip2ThingHandler extends BaseThingHandler {
     }
 
     /**
-     * Process the incoming Resource to initialize the effects channel.
+     * Process the incoming Resource to initialize the fixed resp. timed effects channel.
      *
-     * @param resource a Resource possibly with an Effects element.
+     * @param resource a Resource possibly containing a fixed and/or timed effects element.
      */
     public void updateEffectChannel(Resource resource) {
-        Effects effects = resource.getEffects();
-        if (Objects.nonNull(effects)) {
-            List<StateOption> stateOptions = effects.getStatusValues().stream()
-                    .map(effect -> EffectType.of(effect).name()).map(effectId -> new StateOption(effectId, effectId))
-                    .collect(Collectors.toList());
-            if (!stateOptions.isEmpty()) {
-                stateDescriptionProvider.setStateOptions(new ChannelUID(thing.getUID(), CHANNEL_2_EFFECT),
-                        stateOptions);
-                logger.debug("{} -> updateEffects() found {} effects", resourceId, stateOptions.size());
-            }
+        Effects fixedEffects = resource.getFixedEffects();
+        TimedEffects timedEffects = resource.getTimedEffects();
+        List<StateOption> stateOptions = Stream
+                .concat(Objects.nonNull(fixedEffects) ? fixedEffects.getStatusValues().stream() : Stream.empty(),
+                        Objects.nonNull(timedEffects) ? timedEffects.getStatusValues().stream() : Stream.empty())
+                .map(effect -> {
+                    String effectName = EffectType.of(effect).name();
+                    return new StateOption(effectName, effectName);
+                }).distinct().collect(Collectors.toList());
+        if (!stateOptions.isEmpty()) {
+            stateDescriptionProvider.setStateOptions(new ChannelUID(thing.getUID(), CHANNEL_2_EFFECT), stateOptions);
+            logger.debug("{} -> updateEffects() found {} effects", resourceId, stateOptions.size());
         }
     }
 
index fb32ce5743f9cf70b980bc03bee1ec216939e8a1..84e7623e61ee242786354f30e068aeae4599ec06 100644 (file)
@@ -18,6 +18,7 @@ import java.io.BufferedReader;
 import java.io.FileReader;
 import java.io.IOException;
 import java.lang.reflect.Field;
+import java.time.Duration;
 import java.util.List;
 import java.util.Optional;
 import java.util.Set;
@@ -28,6 +29,7 @@ import org.openhab.binding.hue.internal.dto.clip2.ActionEntry;
 import org.openhab.binding.hue.internal.dto.clip2.Alerts;
 import org.openhab.binding.hue.internal.dto.clip2.Button;
 import org.openhab.binding.hue.internal.dto.clip2.Dimming;
+import org.openhab.binding.hue.internal.dto.clip2.Effects;
 import org.openhab.binding.hue.internal.dto.clip2.Event;
 import org.openhab.binding.hue.internal.dto.clip2.LightLevel;
 import org.openhab.binding.hue.internal.dto.clip2.MetaData;
@@ -42,11 +44,13 @@ import org.openhab.binding.hue.internal.dto.clip2.Resources;
 import org.openhab.binding.hue.internal.dto.clip2.Rotation;
 import org.openhab.binding.hue.internal.dto.clip2.RotationEvent;
 import org.openhab.binding.hue.internal.dto.clip2.Temperature;
+import org.openhab.binding.hue.internal.dto.clip2.TimedEffects;
 import org.openhab.binding.hue.internal.dto.clip2.enums.ActionType;
 import org.openhab.binding.hue.internal.dto.clip2.enums.Archetype;
 import org.openhab.binding.hue.internal.dto.clip2.enums.BatteryStateType;
 import org.openhab.binding.hue.internal.dto.clip2.enums.ButtonEventType;
 import org.openhab.binding.hue.internal.dto.clip2.enums.DirectionType;
+import org.openhab.binding.hue.internal.dto.clip2.enums.EffectType;
 import org.openhab.binding.hue.internal.dto.clip2.enums.ResourceType;
 import org.openhab.binding.hue.internal.dto.clip2.enums.RotationEventType;
 import org.openhab.binding.hue.internal.dto.clip2.enums.ZigbeeStatus;
@@ -597,4 +601,115 @@ class Clip2DtoTest {
         assertEquals("db4fd630-3798-40de-b642-c1ef464bf770", service.getId());
         assertEquals(ResourceType.GROUPED_LIGHT, service.getType());
     }
+
+    @Test
+    void testFixedEffectSetter() {
+        Resource source;
+        Resource target;
+        Effects resultEffect;
+
+        // no source effects
+        source = new Resource(ResourceType.LIGHT);
+        target = new Resource(ResourceType.LIGHT);
+        Setters.setResource(target, source);
+        assertNull(target.getFixedEffects());
+
+        // valid source fixed effects
+        source = new Resource(ResourceType.LIGHT).setFixedEffects(
+                new Effects().setStatusValues(List.of("NO_EFFECT", "SPARKLE", "CANDLE")).setEffect(EffectType.SPARKLE));
+        target = new Resource(ResourceType.LIGHT);
+        Setters.setResource(target, source);
+        resultEffect = target.getFixedEffects();
+        assertNotNull(resultEffect);
+        assertEquals(EffectType.SPARKLE, resultEffect.getEffect());
+        assertEquals(3, resultEffect.getStatusValues().size());
+
+        // valid but different source and target fixed effects
+        source = new Resource(ResourceType.LIGHT).setFixedEffects(
+                new Effects().setStatusValues(List.of("NO_EFFECT", "SPARKLE", "CANDLE")).setEffect(EffectType.SPARKLE));
+        target = new Resource(ResourceType.LIGHT).setFixedEffects(
+                new Effects().setStatusValues(List.of("NO_EFFECT", "FIRE")).setEffect(EffectType.FIRE));
+        Setters.setResource(target, source);
+        resultEffect = target.getFixedEffects();
+        assertNotNull(resultEffect);
+        assertNotEquals(EffectType.SPARKLE, resultEffect.getEffect());
+        assertEquals(3, resultEffect.getStatusValues().size());
+
+        // partly valid source fixed effects
+        source = new Resource(ResourceType.LIGHT).setFixedEffects(new Effects().setStatusValues(List.of("SPARKLE"))
+                .setEffect(EffectType.SPARKLE).setStatusValues(List.of()));
+        target = new Resource(ResourceType.LIGHT);
+        Setters.setResource(target, source);
+        resultEffect = target.getFixedEffects();
+        assertNotNull(resultEffect);
+        assertEquals(EffectType.SPARKLE, resultEffect.getEffect());
+        assertEquals(0, resultEffect.getStatusValues().size());
+        assertFalse(resultEffect.allows(EffectType.SPARKLE));
+        assertFalse(resultEffect.allows(EffectType.NO_EFFECT));
+    }
+
+    @Test
+    void testTimedEffectSetter() {
+        Resource source;
+        Resource target;
+        Effects resultEffect;
+
+        // no source effects
+        source = new Resource(ResourceType.LIGHT);
+        target = new Resource(ResourceType.LIGHT);
+        Setters.setResource(target, source);
+        assertNull(target.getTimedEffects());
+
+        // valid source timed effects
+        source = new Resource(ResourceType.LIGHT).setTimedEffects((TimedEffects) new TimedEffects()
+                .setStatusValues(List.of("NO_EFFECT", "SUNRISE")).setEffect(EffectType.NO_EFFECT));
+        target = new Resource(ResourceType.LIGHT);
+        Setters.setResource(target, source);
+        resultEffect = target.getTimedEffects();
+        assertNotNull(resultEffect);
+        assertEquals(EffectType.NO_EFFECT, resultEffect.getEffect());
+        assertEquals(2, resultEffect.getStatusValues().size());
+
+        // valid but different source and target timed effects
+        source = new Resource(ResourceType.LIGHT)
+                .setTimedEffects((TimedEffects) new TimedEffects().setDuration(Duration.ofMinutes(11))
+                        .setStatusValues(List.of("NO_EFFECT", "SPARKLE", "CANDLE")).setEffect(EffectType.SPARKLE));
+        target = new Resource(ResourceType.LIGHT).setTimedEffects((TimedEffects) new TimedEffects()
+                .setStatusValues(List.of("NO_EFFECT", "FIRE")).setEffect(EffectType.FIRE));
+        Setters.setResource(target, source);
+        resultEffect = target.getTimedEffects();
+        assertNotNull(resultEffect);
+        assertNotEquals(EffectType.SPARKLE, resultEffect.getEffect());
+        assertEquals(3, resultEffect.getStatusValues().size());
+        assertTrue(resultEffect instanceof TimedEffects);
+        assertEquals(Duration.ofMinutes(11), ((TimedEffects) resultEffect).getDuration());
+
+        // partly valid source timed effects
+        source = new Resource(ResourceType.LIGHT).setTimedEffects((TimedEffects) new TimedEffects()
+                .setStatusValues(List.of("SUNRISE")).setEffect(EffectType.SUNRISE).setStatusValues(List.of()));
+        target = new Resource(ResourceType.LIGHT);
+        Setters.setResource(target, source);
+        resultEffect = target.getTimedEffects();
+        assertNotNull(resultEffect);
+        assertEquals(EffectType.SUNRISE, resultEffect.getEffect());
+        assertEquals(0, resultEffect.getStatusValues().size());
+        assertFalse(resultEffect.allows(EffectType.SPARKLE));
+        assertFalse(resultEffect.allows(EffectType.NO_EFFECT));
+        assertTrue(resultEffect instanceof TimedEffects);
+        assertNull(((TimedEffects) resultEffect).getDuration());
+
+        target.setTimedEffectsDuration(Duration.ofSeconds(22));
+        assertEquals(Duration.ofSeconds(22), ((TimedEffects) resultEffect).getDuration());
+
+        // source timed effect with duration
+        source = new Resource(ResourceType.LIGHT)
+                .setTimedEffects((TimedEffects) new TimedEffects().setDuration(Duration.ofMillis(44))
+                        .setStatusValues(List.of("SUNRISE")).setEffect(EffectType.SUNRISE).setStatusValues(List.of()));
+        target = new Resource(ResourceType.LIGHT);
+        Setters.setResource(target, source);
+        resultEffect = target.getTimedEffects();
+        assertNotNull(resultEffect);
+        assertTrue(resultEffect instanceof TimedEffects);
+        assertEquals(Duration.ofMillis(44), ((TimedEffects) resultEffect).getDuration());
+    }
 }