view add_power_transition.patch @ 556:ac035949357d default tip master

Finish some patches
author Louis Opter <louis@opter.org>
date Thu, 18 May 2017 12:09:23 -0700
parents b5efb1c879c6
children
line wrap: on
line source

# HG changeset patch
# Parent  360a2a58c0124bf0144ee6c351bf8a67dfec951a
Add a transition argument to the power functions

Unlike LIFX's implementation, lightsd will properly get the bulbs to
update their brightness state during the transition. That is: if you
call get_light_state during the transition you will see the brightness
of the bulb being updated.

This is achieved by combining SET_LIGHT_COLOR and SET_POWER_STATE
commands in a compatible way with the official LIFX mobile apps.

This feature doesn't work for Original bulbs still running firmware 1.1
since you can't set their color while they are off. The oldest firmware
version tested for the Original bulbs is 1.5.

diff --git a/CMakeLists.txt b/CMakeLists.txt
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -98,6 +98,7 @@
 
 ADD_SUBDIRECTORY(compat)
 ADD_SUBDIRECTORY(core)
+ADD_SUBDIRECTORY(effects)
 ADD_SUBDIRECTORY(lifx)
 
 # 2.8.11 is the first version with TARGET_INCLUDE_DIRECTORIES:
diff --git a/compat/Darwin/time_monotonic.c b/compat/Darwin/time_monotonic.c
--- a/compat/Darwin/time_monotonic.c
+++ b/compat/Darwin/time_monotonic.c
@@ -22,6 +22,7 @@
 #include <mach/mach_time.h>
 #include <sys/time.h>
 #include <assert.h>
+#include <limits.h>
 #include <stdint.h>
 
 #include "time_monotonic.h"
@@ -40,3 +41,21 @@
 
     return time * timebase.numer / timebase.denom / MSECS_IN_NSEC;
 }
+
+int
+lgtd_time_mono_cmp(lgtd_time_mono_t t1, lgtd_time_mono_t t2, int error_margin)
+{
+    assert(error_margin >= 0);
+
+    int cmp;
+    lgtd_time_mono_t diff;
+    if (t1 > t2) {
+        diff = t1 - t2;
+        cmp = 1;
+    } else {
+        diff = t2 - t1;
+        cmp = -1;
+    }
+
+    return diff <= INT_MAX && (int)diff <= error_margin ? 0 : cmp;
+}
diff --git a/compat/Darwin/time_monotonic.h b/compat/Darwin/time_monotonic.h
--- a/compat/Darwin/time_monotonic.h
+++ b/compat/Darwin/time_monotonic.h
@@ -20,3 +20,10 @@
 typedef uint64_t lgtd_time_mono_t;
 
 lgtd_time_mono_t lgtd_time_monotonic_msecs(void);
+
+// Returns:
+//
+// - 0 if abs(t1 - t2) <= error_margin;
+// - -1 if t2 > t1;
+// - 1 if t1 > t2.
+int lgtd_time_mono_cmp(lgtd_time_mono_t, lgtd_time_mono_t, int);
diff --git a/compat/generic/time_monotonic.c b/compat/generic/time_monotonic.c
--- a/compat/generic/time_monotonic.c
+++ b/compat/generic/time_monotonic.c
@@ -15,6 +15,8 @@
 // You should have received a copy of the GNU General Public License
 // along with lighstd.  If not, see <http://www.gnu.org/licenses/>.
 
+#include <assert.h>
+#include <limits.h>
 #include <stdint.h>
 #include <time.h>
 
@@ -27,3 +29,21 @@
     clock_gettime(CLOCK_MONOTONIC, &tp);
     return tp.tv_sec * 1000 + tp.tv_nsec / 1000000;
 }
+
+int
+lgtd_time_mono_cmp(lgtd_time_mono_t t1, lgtd_time_mono_t t2, int error_margin)
+{
+    assert(error_margin >= 0);
+
+    int cmp;
+    lgtd_time_mono_t diff;
+    if (t1 > t2) {
+        diff = t1 - t2;
+        cmp = 1;
+    } else {
+        diff = t2 - t1;
+        cmp = -1;
+    }
+
+    return diff <= INT_MAX && (int)diff <= error_margin ? 0 : cmp;
+}
diff --git a/compat/generic/time_monotonic.h b/compat/generic/time_monotonic.h
--- a/compat/generic/time_monotonic.h
+++ b/compat/generic/time_monotonic.h
@@ -20,3 +20,10 @@
 typedef uint64_t lgtd_time_mono_t;
 
 lgtd_time_mono_t lgtd_time_monotonic_msecs(void);
+
+// Returns:
+//
+// - 0 if abs(t1 - t2) <= error_margin;
+// - -1 if t2 > t1;
+// - 1 if t1 > t2.
+int lgtd_time_mono_cmp(lgtd_time_mono_t, lgtd_time_mono_t, int);
diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt
--- a/core/CMakeLists.txt
+++ b/core/CMakeLists.txt
@@ -3,8 +3,7 @@
 
 INCLUDE_DIRECTORIES(
     ${CMAKE_CURRENT_SOURCE_DIR}/../
-    ${CMAKE_CURRENT_SOURCE_DIR}
-    ${CMAKE_CURRENT_BINARY_DIR}/../
+    ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_BINARY_DIR}/../
     ${CMAKE_CURRENT_BINARY_DIR}
 )
 
@@ -15,6 +14,7 @@
     client.c
     console.c
     daemon.c
+    effect.c
     jsmn.c
     jsonrpc.c
     listen.c
@@ -30,7 +30,7 @@
 )
 
 TARGET_LINK_LIBRARIES(
-    lightsd lifx ${EVENT2_CORE_LIBRARY} ${TIME_MONOTONIC_LIBRARY}
+    lightsd effects lifx ${EVENT2_CORE_LIBRARY} ${TIME_MONOTONIC_LIBRARY}
 )
 
 INSTALL(TARGETS lightsd RUNTIME DESTINATION bin)
diff --git a/core/effect.c b/core/effect.c
new file mode 100644
--- /dev/null
+++ b/core/effect.c
@@ -0,0 +1,349 @@
+// Copyright (c) 2015, Louis Opter <kalessin@kalessin.fr>
+//
+// This file is part of lighstd.
+//
+// lighstd is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// lighstd is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with lighstd.  If not, see <http://www.gnu.org/licenses/>.
+
+#include <sys/queue.h>
+#include <sys/tree.h>
+#include <assert.h>
+#include <endian.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdlib.h>
+#include <time.h>
+
+#include <event2/event.h>
+
+#include "time_monotonic.h"
+#include "timer.h"
+#include "lifx/wire_proto.h"
+#include "proto.h"
+#include "effect.h"
+#include "lightsd.h"
+
+struct lgtd_effect_list lgtd_effects = LIST_HEAD_INITIALIZER(&lgtd_effects);
+
+void
+lgtd_effect_stop(struct lgtd_effect *effect)
+{
+    assert(effect);
+
+    lgtd_info("ending effect %s, id=%p", effect->name, effect);
+    LIST_REMOVE(effect, link);
+    if (effect->stop_cb) {
+        effect->stop_cb(effect);
+    }
+    if (effect->timer) {
+        lgtd_timer_stop(effect->timer);
+    }
+    free(effect);
+}
+
+static void
+lgtd_effect_duration_callback(struct lgtd_timer *timer, union lgtd_timer_ctx ctx)
+{
+    (void)timer;
+
+    struct lgtd_effect *effect = (struct lgtd_effect *)ctx.as_ptr;
+
+    lgtd_time_mono_t now = lgtd_time_monotonic_msecs();
+    lgtd_time_mono_t ends_at = effect->ends_at;
+
+    // Reschedule the timer if it fired off too early:
+    int error_margin = LGTD_EFFECT_TIMER_RESCHEDULE_THRESHOLD_MS;
+    if (lgtd_time_mono_cmp(ends_at, now, error_margin) > 0) {
+        lgtd_time_mono_t timeout = ends_at - now;
+        lgtd_info(
+            "re-scheduling duration callback for effect %s, id=%p in %jums "
+            "(now=%ju, ends_at=%ju)",
+            effect->name, effect, (uintmax_t)timeout,
+            (uintmax_t)now, (uintmax_t)ends_at
+        );
+        struct timeval tv = LGTD_MSECS_TO_TIMEVAL(timeout);
+        if (!lgtd_timer_reschedule(timer, &tv)) {
+            lgtd_warn(
+                "can't re-schedule the duration callback for effect %s, id=%p",
+                effect->name, effect
+            );
+            lgtd_effect_stop(effect);
+        }
+        return;
+    }
+
+    if (effect->duration_cb) {
+        int error_margin = LGTD_EFFECT_STALE_THRESHOLD_MS;
+        if (!lgtd_time_mono_cmp(ends_at, now, error_margin)) {
+            lgtd_info(
+                "calling duration callback for effect %s, id=%p",
+                effect->name, effect
+            );
+            effect->duration_cb(effect);
+        } else {
+            lgtd_warnx(
+                "not calling duration callback for stale effect %s created "
+                "%jums ago, id=%p, duration=%jums",
+                effect->name, (uintmax_t)(now - effect->created_at), effect,
+                (uintmax_t)(effect->ends_at - effect->created_at)
+            );
+        }
+    }
+    lgtd_effect_stop(effect);
+}
+
+static void
+lgtd_effect_timer_callback(struct lgtd_timer *timer, union lgtd_timer_ctx ctx)
+{
+    struct lgtd_effect *effect = (struct lgtd_effect *)ctx.as_ptr;
+
+    int recursions = 0;
+    do {
+        if (effect->apply_left < 0) {
+            lgtd_warnx(
+                "stopping runaway effect %s, id=%p, apply_left=%d",
+                effect->name, effect, effect->apply_left
+            );
+            lgtd_effect_stop(effect);
+            return;
+        }
+
+        lgtd_time_mono_t now = lgtd_time_monotonic_msecs();
+        lgtd_time_mono_t callback_started_at = now;
+        lgtd_time_mono_t ends_at = effect->ends_at;
+
+        int error_margin = LGTD_EFFECT_STALE_THRESHOLD_MS;
+        if (ends_at && lgtd_time_mono_cmp(now, ends_at, error_margin) > 0) {
+            // check if the effect is stale (e.g: the computer went to sleep)
+            // and stop it if it is the case:
+            lgtd_warnx(
+                "stopping stale periodic effect %s created %jums ago, "
+                "id=%p, duration=%jums",
+                effect->name, (uintmax_t)(now - effect->created_at), effect,
+                (uintmax_t)(effect->ends_at - effect->created_at)
+            );
+            lgtd_effect_stop(effect);
+            return;
+        }
+
+        lgtd_info(
+            "calling apply callback for effect %s, id=%p, apply_left=%d",
+            effect->name, effect, effect->apply_left
+        );
+        assert(effect->apply_cb);
+        enum lgtd_effect_flags rv = effect->apply_cb(effect);
+        effect->apply_cnt++;
+
+        error_margin = LGTD_EFFECT_TIMER_RESCHEDULE_THRESHOLD_MS;
+        if (ends_at && effect->apply_left-- == 0) {
+            if (lgtd_time_mono_cmp(now, ends_at, error_margin) <= 0) {
+                // now <= ends_at and we aren't stale, call the duration
+                // callback now and/or stop the effect.
+                if (effect->duration_cb) {
+                    lgtd_info(
+                        "calling duration callback for effect %s, id=%p",
+                        effect->name, effect
+                    );
+                    effect->duration_cb(effect);
+                }
+                lgtd_effect_stop(effect);
+                return;
+            }
+
+            if (!effect->duration_cb) {
+                lgtd_effect_stop(effect);
+                return;
+            }
+
+            // now > ends_at, schedule the duration callback
+            lgtd_timer_stop(timer);
+            now = lgtd_time_monotonic_msecs(); // refresh
+            lgtd_time_mono_t wait = now - ends_at;
+            effect->timer = lgtd_timer_start(
+                LGTD_TIMER_DEFAULT_FLAGS,
+                wait,
+                lgtd_effect_duration_callback,
+                ctx
+            );
+            if (!effect->timer) {
+                lgtd_warn(
+                    "can't schedule the duration callback for effect %s, id=%p",
+                    effect->name, effect
+                );
+                lgtd_effect_stop(effect);
+                return;
+            }
+            lgtd_info(
+                "scheduled duration callback for effect %s, id=%p, in %jums",
+                effect->name, effect, (uintmax_t)wait
+            );
+            return;
+        }
+
+        if (rv == LGTD_EFFECT_STOP) {
+            lgtd_effect_stop(effect);
+            return;
+        }
+
+        // schedule the next call or recurse if we actually spent enough time
+        // here to reach next_tick_at:
+        lgtd_time_mono_t next_tick_at = callback_started_at + effect->timer_ms;
+        now = lgtd_time_monotonic_msecs(); // refresh
+        if (lgtd_time_mono_cmp(next_tick_at, now, error_margin) > 0) {
+            struct timeval tv = LGTD_MSECS_TO_TIMEVAL(next_tick_at - now);
+            lgtd_timer_reschedule(timer, &tv);
+            return;
+        } else if (++recursions == LGTD_EFFECT_MAX_APPLY_RECURSIONS) {
+            lgtd_warnx(
+                "stopping runaway effect after %d recursive calls %s, id=%p",
+                recursions, effect->name, effect
+            );
+            lgtd_effect_stop(effect);
+            return;
+        }
+    } while (1);
+}
+
+struct lgtd_effect *
+lgtd_effect_start(const char *name,
+                  int duration,
+                  void (*duration_cb)(const struct lgtd_effect *),
+                  int timer_flags,
+                  int timer_ms,
+                  enum lgtd_effect_flags (*apply_cb)(
+                      const struct lgtd_effect *),
+                  void (*stop_cb)(const struct lgtd_effect *),
+                  union lgtd_effect_ctx ctx)
+{
+    assert(name);
+    assert(duration >= 0);
+    assert(timer_ms >= 0);
+    if (timer_ms && duration) {
+        assert(timer_ms < duration);
+    }
+
+    struct lgtd_effect *effect = calloc(1, sizeof(*effect));
+    if (!effect) {
+        return NULL;
+    }
+
+    effect->name = name;
+    effect->created_at = lgtd_time_monotonic_msecs();
+    effect->ends_at = effect->created_at + duration;
+    effect->duration_cb = duration_cb;
+    effect->timer_ms = timer_ms;
+    effect->apply_cb = apply_cb;
+    effect->stop_cb = stop_cb;
+    effect->ctx = ctx;
+    LIST_INSERT_HEAD(&lgtd_effects, effect, link);
+
+    void (*timer_cb)(struct lgtd_timer *, union lgtd_timer_ctx) = NULL;
+    if (timer_ms) { // periodic or delayed effect
+        assert(apply_cb);
+        if (!apply_cb) {
+            lgtd_warnx(
+                "cannot create periodic or delayed effect %s without a "
+                "callback", name
+            );
+            goto err;
+        }
+        assert(duration || (timer_flags & LGTD_TIMER_PERSISTENT));
+        if (timer_flags & LGTD_TIMER_PERSISTENT) { // finite or infinite periodic effect
+            // disable that and re-schedule the timer at every call to
+            // get execution time into account and therefore slightly
+            // more accurate timings:
+            timer_flags &= ~LGTD_TIMER_PERSISTENT;
+            if (duration) { // finite periodic effect
+                effect->apply_left = duration / timer_ms;
+                if (!(timer_flags & LGTD_TIMER_ACTIVATE_NOW)) {
+                    effect->apply_left--;
+                }
+            }
+        } else { // delayed effect
+            if (!duration) {
+                lgtd_warnx(
+                    "cannot create delayed effect %s without a duration", name
+                );
+                goto err;
+            }
+            if (timer_flags & LGTD_TIMER_ACTIVATE_NOW) {
+                effect->apply_left++; // will be applied now and after timer_ms
+            }
+        }
+        timer_cb = lgtd_effect_timer_callback;
+        if (duration) {
+            lgtd_info(
+                "starting effect %s, id=%p, created_at=%jums, "
+                "tick=%dms, duration=%dms",
+                name, effect, (uintmax_t)effect->created_at, timer_ms, duration
+            );
+        } else {
+            lgtd_info(
+                "starting effect %s, id=%p, created_a=%jums, "
+                "tick=%dms, duration=infinite",
+                name, effect, (uintmax_t)effect->created_at, timer_ms
+            );
+        }
+    } else { // finite or instant effect
+        if (duration) {
+            lgtd_info(
+                "starting effect %s, id=%p, created_at=%jums, "
+                "duration=%dms",
+                name, effect, (uintmax_t)effect->created_at, duration
+            );
+        } else {
+            lgtd_info(
+                "starting effect %s, id=%p, created_at=%jums, "
+                "duration=instant",
+                name, effect, (uintmax_t)effect->created_at
+            );
+        }
+        if (apply_cb) {
+            apply_cb(effect);
+        }
+        if (!duration) { // instant effect
+            if (duration_cb) {
+                duration_cb(effect);
+            }
+            lgtd_effect_stop(effect);
+            return NULL;
+        }
+        timer_ms = duration;
+        timer_flags = LGTD_TIMER_DEFAULT_FLAGS;
+        timer_cb = lgtd_effect_duration_callback;
+    }
+
+    union lgtd_timer_ctx timer_ctx = { .as_ptr = effect };
+    effect->timer = lgtd_timer_start(
+        timer_flags, timer_ms, timer_cb, timer_ctx
+    );
+    if (effect->timer) {
+        return effect;
+    }
+
+err:
+    if (effect->timer) {
+        lgtd_timer_stop(effect->timer);
+    }
+    LIST_REMOVE(effect, link);
+    free(effect);
+    return NULL;
+}
+
+void
+lgtd_effect_stop_all(void)
+{
+    while (!LIST_EMPTY(&lgtd_effects)) {
+        lgtd_effect_stop(LIST_FIRST(&lgtd_effects));
+    }
+}
diff --git a/core/effect.h b/core/effect.h
new file mode 100644
--- /dev/null
+++ b/core/effect.h
@@ -0,0 +1,76 @@
+// Copyright (c) 2015, Louis Opter <kalessin@kalessin.fr>
+//
+// This file is part of lighstd.
+//
+// lighstd is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// lighstd is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with lighstd.  If not, see <http://www.gnu.org/licenses/>.
+
+#pragma once
+
+union lgtd_effect_ctx {
+    uint64_t    as_uint;
+    void        *as_ptr;
+};
+
+enum lgtd_effect_flags {
+    LGTD_EFFECT_CONTINUE = 0,
+    LGTD_EFFECT_STOP = 1
+};
+
+#pragma GCC diagnostic push
+#pragma GCC diagnostic warning "-Wpadded"
+struct lgtd_effect {
+    LIST_ENTRY(lgtd_effect)         link;
+    lgtd_time_mono_t                created_at;
+    lgtd_time_mono_t                ends_at;
+    const char                      *name;
+    struct lgtd_timer               *timer;
+    void                            (*duration_cb)(const struct lgtd_effect *);
+    enum lgtd_effect_flags          (*apply_cb)(const struct lgtd_effect *);
+    void                            (*stop_cb)(const struct lgtd_effect *);
+    int                             timer_ms;
+    // how many times apply_cb will be called again or 0 if your effect
+    // is infinite (duration == 0 and timer_ms > 0) or doesn't use a
+    // timer (timer_ms == 0):
+    int                             apply_left;
+    int                             apply_cnt;
+    union lgtd_effect_ctx           ctx;
+};
+#pragma GCC diagnostic pop
+LIST_HEAD(lgtd_effect_list, lgtd_effect);
+
+enum { LGTD_EFFECT_MAX_APPLY_RECURSIONS = 5 };
+
+enum { LGTD_EFFECT_STALE_THRESHOLD_MS = 2000 };
+enum { LGTD_EFFECT_TIMER_RESCHEDULE_THRESHOLD_MS = 30 };
+
+extern struct lgtd_effect_list lgtd_effects;
+
+static inline uintptr_t
+lgtd_effect_id(const struct lgtd_effect *effect)
+{
+    return (uintptr_t)effect;
+}
+
+// if the duration and apply callback tick at the same time, the apply cb
+// will be executed first.
+struct lgtd_effect *lgtd_effect_start(const char *, // name
+                                      int, // duration
+                                      void (*)(const struct lgtd_effect *), // duration cb
+                                      int, // timer flags
+                                      int, // timer ms
+                                      enum lgtd_effect_flags (*)(const struct lgtd_effect *), // apply cb
+                                      void (*)(const struct lgtd_effect *), // stop cb
+                                      union lgtd_effect_ctx);
+void lgtd_effect_stop(struct lgtd_effect *);
+void lgtd_effect_stop_all(void);
diff --git a/core/jsonrpc.c b/core/jsonrpc.c
--- a/core/jsonrpc.c
+++ b/core/jsonrpc.c
@@ -119,17 +119,6 @@
 }
 
 static bool
-lgtd_jsonrpc_type_number(const jsmntok_t *t, const char *json)
-{
-    if (t->type != JSMN_PRIMITIVE) {
-        return false;
-    }
-
-    char c = json[t->start];
-    return c == '-' || (c >= '0' && c <= '9');
-}
-
-static bool
 lgtd_jsonrpc_type_bool(const jsmntok_t *t, const char *json)
 {
     if (t->type != JSMN_PRIMITIVE) {
@@ -177,20 +166,20 @@
 }
 
 static bool
-lgtd_jsonrpc_type_string_number_or_null(const jsmntok_t *t,
-                                        const char *json)
+lgtd_jsonrpc_type_string_integer_or_null(const jsmntok_t *t,
+                                         const char *json)
 {
-    return lgtd_jsonrpc_type_number(t, json)
+    return lgtd_jsonrpc_type_integer(t, json)
         || lgtd_jsonrpc_type_null(t, json)
         || lgtd_jsonrpc_type_string(t, json);
 }
 
 static bool
-lgtd_jsonrpc_type_string_or_number(const jsmntok_t *t,
-                                   const char *json)
+lgtd_jsonrpc_type_string_or_integer(const jsmntok_t *t,
+                                    const char *json)
 {
     return lgtd_jsonrpc_type_string(t, json)
-        || lgtd_jsonrpc_type_number(t, json);
+        || lgtd_jsonrpc_type_integer(t, json);
 }
 
 static bool __attribute__((unused))
@@ -201,10 +190,10 @@
 }
 
 static bool
-lgtd_jsonrpc_type_string_number_or_array(const jsmntok_t *t, const char *json)
+lgtd_jsonrpc_type_string_integer_or_array(const jsmntok_t *t, const char *json)
 {
     return lgtd_jsonrpc_type_string(t, json)
-        || lgtd_jsonrpc_type_number(t, json)
+        || lgtd_jsonrpc_type_integer(t, json)
         || lgtd_jsonrpc_type_array(t, json);
 }
 
@@ -591,7 +580,7 @@
             "id",
             offsetof(struct lgtd_jsonrpc_request, id),
             -1,
-            lgtd_jsonrpc_type_string_number_or_null,
+            lgtd_jsonrpc_type_string_integer_or_null,
             true
         )
     };
@@ -642,7 +631,7 @@
 
     for (int ti = target_ntokens; ti--;) {
         int token_len = LGTD_JSONRPC_TOKEN_LEN(&target[ti]);
-        if (lgtd_jsonrpc_type_string_or_number(&target[ti], client->json)) {
+        if (lgtd_jsonrpc_type_string_or_integer(&target[ti], client->json)) {
             struct lgtd_proto_target *t = malloc(sizeof(*t) + token_len + 1);
             if (!t) {
                 lgtd_warn("can't allocate a new target");
@@ -691,7 +680,7 @@
             "target",
             offsetof(struct lgtd_jsonrpc_set_light_from_hsbk_args, target),
             offsetof(struct lgtd_jsonrpc_set_light_from_hsbk_args, target_ntokens),
-            lgtd_jsonrpc_type_string_number_or_array,
+            lgtd_jsonrpc_type_string_integer_or_array,
             false
         ),
         LGTD_JSONRPC_NODE(
@@ -804,7 +793,7 @@
             "target",
             offsetof(struct lgtd_jsonrpc_set_waveform_args, target),
             offsetof(struct lgtd_jsonrpc_set_waveform_args, target_ntokens),
-            lgtd_jsonrpc_type_string_number_or_array,
+            lgtd_jsonrpc_type_string_integer_or_array,
             false
         ),
         LGTD_JSONRPC_NODE(
@@ -946,6 +935,89 @@
 }
 
 static bool
+lgtd_jsonrpc_extract_target_list_and_transition(struct lgtd_proto_target_list *targets,
+                                                int *transition,
+                                                struct lgtd_client *client)
+{
+    struct lgtd_jsonrpc_power_args {
+        const jsmntok_t *target;
+        int             target_ntokens;
+        const jsmntok_t *transition;
+    } params = { NULL, 0, NULL };
+    static const struct lgtd_jsonrpc_node schema[] = {
+        LGTD_JSONRPC_NODE(
+            "target",
+            offsetof(struct lgtd_jsonrpc_power_args, target),
+            offsetof(struct lgtd_jsonrpc_power_args, target_ntokens),
+            lgtd_jsonrpc_type_string_integer_or_array,
+            false
+        ),
+        LGTD_JSONRPC_NODE(
+            "transition",
+            offsetof(struct lgtd_jsonrpc_power_args, transition),
+            -1,
+            lgtd_jsonrpc_type_integer,
+            true
+        )
+    };
+
+    assert(transition);
+
+    struct lgtd_jsonrpc_request *req = client->current_request;
+    bool ok = lgtd_jsonrpc_extract_and_validate_params_against_schema(
+        &params,
+        schema,
+        LGTD_ARRAY_SIZE(schema),
+        req->params,
+        req->params_ntokens,
+        client->json
+    );
+    if (!ok) {
+        goto error_invalid_params;
+    }
+
+    if (params.transition) {
+        *transition = strtol(&client->json[params.transition->start], NULL, 10);
+        if (*transition < 0 || errno == ERANGE) {
+            goto error_invalid_params;
+        }
+    } else {
+        *transition = 0;
+    }
+
+    return lgtd_jsonrpc_build_target_list(
+        targets, client, params.target, params.target_ntokens
+    );
+
+error_invalid_params:
+    lgtd_jsonrpc_send_error(
+        client, LGTD_JSONRPC_INVALID_PARAMS, "Invalid parameters"
+    );
+    return false;
+}
+
+#define CHECK_AND_CALL_POWER_METHOD(proto_method)                               \
+static void                                                                     \
+lgtd_jsonrpc_check_and_call_##proto_method(struct lgtd_client *client)          \
+{                                                                               \
+    struct lgtd_proto_target_list targets = SLIST_HEAD_INITIALIZER(&targets);   \
+    int transition = 0;                                                         \
+    bool ok = lgtd_jsonrpc_extract_target_list_and_transition(                  \
+        &targets, &transition, client                                           \
+    );                                                                          \
+    if (!ok) {                                                                  \
+        return;                                                                 \
+    }                                                                           \
+                                                                                \
+    lgtd_proto_##proto_method(client, &targets, transition);                    \
+    lgtd_proto_target_list_clear(&targets);                                     \
+}
+
+CHECK_AND_CALL_POWER_METHOD(power_on);
+CHECK_AND_CALL_POWER_METHOD(power_off);
+CHECK_AND_CALL_POWER_METHOD(power_toggle);
+
+static bool
 lgtd_jsonrpc_extract_target_list(struct lgtd_proto_target_list *targets,
                                  struct lgtd_client *client)
 {
@@ -958,7 +1030,7 @@
             "target",
             offsetof(struct lgtd_jsonrpc_target_args, target),
             offsetof(struct lgtd_jsonrpc_target_args, target_ntokens),
-            lgtd_jsonrpc_type_string_number_or_array,
+            lgtd_jsonrpc_type_string_integer_or_array,
             false
         )
     };
@@ -979,25 +1051,19 @@
     );
 }
 
-#define CHECK_AND_CALL_TARGETS_ONLY_METHOD(proto_method)                       \
-static void                                                                    \
-lgtd_jsonrpc_check_and_call_##proto_method(struct lgtd_client *client)         \
-{                                                                              \
-    struct lgtd_proto_target_list targets = SLIST_HEAD_INITIALIZER(&targets);  \
-    bool ok = lgtd_jsonrpc_extract_target_list(&targets, client);              \
-    if (!ok) {                                                                 \
-        return;                                                                \
-    }                                                                          \
-                                                                               \
-    lgtd_proto_##proto_method(client, &targets);                               \
-    lgtd_proto_target_list_clear(&targets);                                    \
+static void
+lgtd_jsonrpc_check_and_call_get_light_state(struct lgtd_client *client)
+{
+    struct lgtd_proto_target_list targets = SLIST_HEAD_INITIALIZER(&targets);
+    bool ok = lgtd_jsonrpc_extract_target_list(&targets, client);
+    if (!ok) {
+        return;
+    }
+
+    lgtd_proto_get_light_state(client, &targets);
+    lgtd_proto_target_list_clear(&targets);
 }
 
-CHECK_AND_CALL_TARGETS_ONLY_METHOD(power_on);
-CHECK_AND_CALL_TARGETS_ONLY_METHOD(power_off);
-CHECK_AND_CALL_TARGETS_ONLY_METHOD(power_toggle);
-CHECK_AND_CALL_TARGETS_ONLY_METHOD(get_light_state);
-
 static void
 lgtd_jsonrpc_check_and_call_proto_tag_or_untag_or_set_label(
         struct lgtd_client *client,
@@ -1016,7 +1082,7 @@
             "target",
             offsetof(struct lgtd_jsonrpc_target_args, target),
             offsetof(struct lgtd_jsonrpc_target_args, target_ntokens),
-            lgtd_jsonrpc_type_string_number_or_array,
+            lgtd_jsonrpc_type_string_integer_or_array,
             false
         ),
         LGTD_JSONRPC_NODE(
diff --git a/core/lightsd.c b/core/lightsd.c
--- a/core/lightsd.c
+++ b/core/lightsd.c
@@ -51,6 +51,7 @@
 #include "timer.h"
 #include "listen.h"
 #include "daemon.h"
+#include "effect.h"
 #include "lightsd.h"
 
 struct lgtd_opts lgtd_opts = {
@@ -107,7 +108,15 @@
 lgtd_configure_libevent(void)
 {
     event_set_log_callback(lgtd_libevent_log);
+#ifndef NDEBUG
+    event_enable_debug_mode();
+#endif
     lgtd_ev_base = event_base_new();
+    if (lgtd_ev_base &&
+        !event_base_priority_init(lgtd_ev_base, LGTD_EVENT_N_PRIORITIES)) {
+        return;
+    }
+    lgtd_err(1, "can't configure libevent");
 }
 
 static void
@@ -138,8 +147,10 @@
 lgtd_close_signal_handling(void)
 {
     for (int i = 0; i != LGTD_ARRAY_SIZE(lgtd_signals); i++) {
-        event_del(lgtd_signal_evs[i]);
-        event_free(lgtd_signal_evs[i]);
+        if (lgtd_signal_evs[i]) {
+            event_del(lgtd_signal_evs[i]);
+            event_free(lgtd_signal_evs[i]);
+        }
     }
 }
 
@@ -188,11 +199,14 @@
     lgtd_listen_close_all();
     lgtd_command_pipe_close_all();
     lgtd_client_close_all();
+    lgtd_effect_stop_all();
     lgtd_lifx_broadcast_close();
     lgtd_lifx_gateway_close_all();
     lgtd_timer_stop_all();
     lgtd_close_signal_handling();
-    event_base_free(lgtd_ev_base);
+    if (lgtd_ev_base) {
+        event_base_free(lgtd_ev_base);
+    }
 #if LIBEVENT_VERSION_NUMBER >= 0x02010100
     libevent_global_shutdown();
 #endif
diff --git a/core/lightsd.h b/core/lightsd.h
--- a/core/lightsd.h
+++ b/core/lightsd.h
@@ -97,6 +97,15 @@
     LGTD_ERR
 };
 
+// XXX: Update enum lgtd_timer_flags in timer.h when updating this:
+enum lgtd_event_priority {
+    LGTD_EVENT_PRIORITY_HIGHEST = 0,
+    LGTD_EVENT_PRIORITY_LOWEST = 2,
+    // see libevent documentation:
+    LGTD_EVENT_N_PRIORITIES = LGTD_EVENT_PRIORITY_LOWEST + 1,
+    LGTD_EVENT_PRIORITY_DEFAULT = LGTD_EVENT_N_PRIORITIES / 2,
+};
+
 enum { LGTD_ERROR_MSG_BUFSIZE = 2048 };
 
 // FIXME: introspect sizeof(sockaddr_un.sun_path) with CMake to generate a
@@ -137,9 +146,9 @@
 extern struct event_base *lgtd_ev_base;
 extern const char *lgtd_progname;
 
-char *lgtd_iee8023mactoa(const uint8_t *addr, char *buf, int buflen);
+char *lgtd_ieee8023mactoa(const uint8_t *addr, char *buf, int buflen);
 #define LGTD_IEEE8023MACTOA(addr, buf) \
-    lgtd_iee8023mactoa((addr), (buf), sizeof(buf))
+    lgtd_ieee8023mactoa((addr), (buf), sizeof(buf))
 char *lgtd_sockaddrtoa(const struct sockaddr *, char *buf, int buflen);
 #define LGTD_SOCKADDRTOA(addr, buf) \
     lgtd_sockaddrtoa((addr), (buf), sizeof(buf))
diff --git a/core/proto.c b/core/proto.c
--- a/core/proto.c
+++ b/core/proto.c
@@ -39,6 +39,7 @@
 #include "client.h"
 #include "lifx/gateway.h"
 #include "proto.h"
+#include "effects/power_transition.h"
 #include "router.h"
 #include "lightsd.h"
 
@@ -58,23 +59,68 @@
     }
 }
 
+int
+lgtd_proto_target_list_len(const struct lgtd_proto_target_list *targets)
+{
+    // TODO: carry the len of the list in the struct and set it from
+    //       lgtd_jsonrpc_build_target_list.
+    int len = 0;
+    struct lgtd_proto_target *it;
+    SLIST_FOREACH(it, targets, link) {
+        len++;
+    }
+    return len;
+}
+
 void
 lgtd_proto_power_on(struct lgtd_client *client,
-                    const struct lgtd_proto_target_list *targets)
+                    const struct lgtd_proto_target_list *targets,
+                    int transition)
 {
     assert(targets);
+    assert(transition >= 0);
 
-    struct lgtd_lifx_packet_power_state pkt = { .power = LGTD_LIFX_POWER_ON };
-    SEND_RESULT(
-        client, lgtd_router_send(targets, LGTD_LIFX_SET_POWER_STATE, &pkt)
-    );
+    bool ok;
+    if (transition) {
+        ok = lgtd_effect_power_transition(
+            targets, LGTD_EFFECT_POWER_TRANSITION_ON, transition
+        );
+    } else {
+        struct lgtd_lifx_packet_power pkt = { .power = LGTD_LIFX_POWER_ON };
+        ok = lgtd_router_send(targets, LGTD_LIFX_SET_POWER_STATE, &pkt);
+    }
+
+    SEND_RESULT(client, ok);
+}
+
+void
+lgtd_proto_power_off(struct lgtd_client *client,
+                     const struct lgtd_proto_target_list *targets,
+                     int transition)
+{
+    assert(targets);
+    assert(transition >= 0);
+
+    bool ok;
+    if (transition) {
+        ok = lgtd_effect_power_transition(
+            targets, LGTD_EFFECT_POWER_TRANSITION_OFF, transition
+        );
+    } else {
+        struct lgtd_lifx_packet_power pkt = { .power = LGTD_LIFX_POWER_OFF };
+        ok = lgtd_router_send(targets, LGTD_LIFX_SET_POWER_STATE, &pkt);
+    }
+
+    SEND_RESULT(client, ok);
 }
 
 void
 lgtd_proto_power_toggle(struct lgtd_client *client,
-                        const struct lgtd_proto_target_list *targets)
+                        const struct lgtd_proto_target_list *targets,
+                        int transition)
 {
     assert(targets);
+    assert(transition >= 0);
 
     struct lgtd_router_device_list *devices = NULL;
     devices = lgtd_router_targets_to_devices(targets);
@@ -85,33 +131,67 @@
         return;
     }
 
+    struct lgtd_proto_target_list targets_to_turn_off =
+        SLIST_HEAD_INITIALIZER(&targets_to_turn_off);
+    struct lgtd_proto_target_list targets_to_turn_on =
+        SLIST_HEAD_INITIALIZER(&targets_to_turn_on);
+
+    bool ok = true;
     struct lgtd_router_device *device;
     SLIST_FOREACH(device, devices, link) {
         struct lgtd_lifx_bulb *bulb = device->device;
-        struct lgtd_lifx_packet_power_state pkt = {
-            .power = ~bulb->state.power
-        };
-        lgtd_router_send_to_device(bulb, LGTD_LIFX_SET_POWER_STATE, &pkt);
+        if (transition) {
+            struct lgtd_proto_target *target = malloc(
+                offsetof(struct lgtd_proto_target, target)
+                + LGTD_LIFX_ADDR_STRLEN
+            );
+            if (!target) {
+                lgtd_warn("couldn't toggle bulbs");
+                ok = false;
+                break;
+            }
+            snprintf(
+                target->target, LGTD_LIFX_ADDR_STRLEN,
+                "%02x%02x%02x%02x%02x%02x",
+                bulb->addr[0], bulb->addr[1], bulb->addr[2], bulb->addr[3],
+                bulb->addr[4], bulb->addr[5]
+            );
+            SLIST_INSERT_HEAD(
+                bulb->state.power ? &targets_to_turn_off : &targets_to_turn_on,
+                target,
+                link
+            );
+        } else {
+            struct lgtd_lifx_packet_power_state pkt = {
+                .power = ~bulb->state.power
+            };
+            lgtd_router_send_to_device(bulb, LGTD_LIFX_SET_POWER_STATE, &pkt);
+        }
     }
 
-    SEND_RESULT(client, true);
+    if (transition) {
+        if (!SLIST_EMPTY(&targets_to_turn_on)) {
+            ok = ok && lgtd_effect_power_transition(
+                &targets_to_turn_on, LGTD_EFFECT_POWER_TRANSITION_ON, transition
+            );
+            lgtd_proto_target_list_clear(&targets_to_turn_on);
+        }
+        if (!SLIST_EMPTY(&targets_to_turn_off)) {
+            ok = ok && lgtd_effect_power_transition(
+                &targets_to_turn_off,
+                LGTD_EFFECT_POWER_TRANSITION_OFF,
+                transition
+            );
+            lgtd_proto_target_list_clear(&targets_to_turn_off);
+        }
+    }
+
+    SEND_RESULT(client, ok);
 
     lgtd_router_device_list_free(devices);
 }
 
 void
-lgtd_proto_power_off(struct lgtd_client *client,
-                     const struct lgtd_proto_target_list *targets)
-{
-    assert(targets);
-
-    struct lgtd_lifx_packet_power_state pkt = { .power = LGTD_LIFX_POWER_OFF };
-    SEND_RESULT(
-        client, lgtd_router_send(targets, LGTD_LIFX_SET_POWER_STATE, &pkt)
-    );
-}
-
-void
 lgtd_proto_set_light_from_hsbk(struct lgtd_client *client,
                                const struct lgtd_proto_target_list *targets,
                                int hue,
diff --git a/core/proto.h b/core/proto.h
--- a/core/proto.h
+++ b/core/proto.h
@@ -17,6 +17,8 @@
 
 #pragma once
 
+struct lgtd_client;
+
 struct lgtd_proto_target {
     SLIST_ENTRY(lgtd_proto_target)  link;
     char                            target[];
@@ -24,6 +26,7 @@
 SLIST_HEAD(lgtd_proto_target_list, lgtd_proto_target);
 
 void lgtd_proto_target_list_clear(struct lgtd_proto_target_list *);
+int lgtd_proto_target_list_len(const struct lgtd_proto_target_list *);
 const struct lgtd_proto_target *lgtd_proto_target_list_add(struct lgtd_client *,
                                                            struct lgtd_proto_target_list *,
                                                            const char *, int);
@@ -36,9 +39,9 @@
                              enum lgtd_lifx_waveform_type,
                              int, int, int, int,
                              int, float, int, bool);
-void lgtd_proto_power_on(struct lgtd_client *, const struct lgtd_proto_target_list *);
-void lgtd_proto_power_off(struct lgtd_client *, const struct lgtd_proto_target_list *);
-void lgtd_proto_power_toggle(struct lgtd_client *, const struct lgtd_proto_target_list *);
+void lgtd_proto_power_on(struct lgtd_client *, const struct lgtd_proto_target_list *, int);
+void lgtd_proto_power_off(struct lgtd_client *, const struct lgtd_proto_target_list *, int);
+void lgtd_proto_power_toggle(struct lgtd_client *, const struct lgtd_proto_target_list *, int);
 void lgtd_proto_get_light_state(struct lgtd_client *, const struct lgtd_proto_target_list *);
 void lgtd_proto_tag(struct lgtd_client *, const struct lgtd_proto_target_list *, const char *);
 void lgtd_proto_untag(struct lgtd_client *, const struct lgtd_proto_target_list *, const char *);
diff --git a/core/router.h b/core/router.h
--- a/core/router.h
+++ b/core/router.h
@@ -17,6 +17,8 @@
 
 #pragma once
 
+struct lgtd_lifx_tag;
+
 // TODO: return that from the functions in there and handle it:
 enum lgtd_router_error {
     LGTD_ROUTER_INVALID_TARGET_ERROR,
diff --git a/core/timer.c b/core/timer.c
--- a/core/timer.c
+++ b/core/timer.c
@@ -21,6 +21,7 @@
 #include <stdbool.h>
 #include <stdint.h>
 #include <stdlib.h>
+#include <strings.h>
 
 #include <event2/event.h>
 #include <event2/util.h>
@@ -42,6 +43,17 @@
     timer->callback(timer, timer->ctx);
 }
 
+static enum lgtd_event_priority
+lgtd_timer_flags_to_priority(int flags)
+{
+    flags &= LGTD_TIMER_FLAGS_EVENT_PRIORITY_MASK;
+    if (!flags) {
+        return LGTD_EVENT_PRIORITY_DEFAULT;
+    }
+
+    return INT_NBITS - ffs(flags);
+}
+
 struct lgtd_timer *
 lgtd_timer_start(int flags,
                  int ms,
@@ -68,7 +80,9 @@
         lgtd_timer_callback,
         timer
     );
-    if (!timer->event || evtimer_add(timer->event, &tv)) {
+    if (!timer->event
+        || event_priority_set(timer->event, lgtd_timer_flags_to_priority(flags))
+        || evtimer_add(timer->event, &tv)) {
         LIST_REMOVE(timer, link);
         if (timer->event) {
             event_free(timer->event);
diff --git a/core/timer.h b/core/timer.h
--- a/core/timer.h
+++ b/core/timer.h
@@ -33,10 +33,20 @@
 };
 LIST_HEAD(lgtd_timer_list, lgtd_timer);
 
+#define INT_NBITS (sizeof(int) * 8)
 enum lgtd_timer_flags {
     LGTD_TIMER_DEFAULT_FLAGS = 0,
     LGTD_TIMER_ACTIVATE_NOW  = 1,
     LGTD_TIMER_PERSISTENT    = 1 << 1,
+    // XXX: or do the opposite (reserve the first 8 bits for those
+    //      flags so we actually don't have to do any conversion):
+    LGTD_TIMER_EVENT_PRIORITY_LOWEST = 1 << (INT_NBITS - 3),
+    LGTD_TIMER_EVENT_PRIORITY_DEFAULT = 1 << (INT_NBITS - 2),
+    LGTD_TIMER_EVENT_PRIORITY_HIGHEST = 1 << (INT_NBITS - 1),
+};
+enum {
+    LGTD_TIMER_FLAGS_EVENT_PRIORITY_MASK =
+        ~(LGTD_TIMER_EVENT_PRIORITY_LOWEST - 1)
 };
 
 // Activate the timer now, in other words make the callback pending:
@@ -70,7 +80,7 @@
 void lgtd_timer_stop_all(void);
 // NOTE: if you start a persistent timer and don't keep track of it, make sure
 //       you don't end up in a callback using a context that has been freed.
-struct lgtd_timer *lgtd_timer_start(int,
+struct lgtd_timer *lgtd_timer_start(int, // flags
                                     int, // ms
                                     void (*)(struct lgtd_timer *,
                                              union lgtd_timer_ctx),
diff --git a/core/utils.c b/core/utils.c
--- a/core/utils.c
+++ b/core/utils.c
@@ -30,7 +30,7 @@
 #include "lightsd.h"
 
 char *
-lgtd_iee8023mactoa(const uint8_t *addr, char *buf, int buflen)
+lgtd_ieee8023mactoa(const uint8_t *addr, char *buf, int buflen)
 {
     assert(addr);
     assert(buf);
diff --git a/docs/protocol.rst b/docs/protocol.rst
--- a/docs/protocol.rst
+++ b/docs/protocol.rst
@@ -51,17 +51,27 @@
 Available methods
 -----------------
 
-.. function:: power_off(target)
-
-   Power off the given bulb(s).
+.. function:: power_off(target[, transition])
 
-.. function:: power_on(target)
+   Power off the given bulb(s) with an optional transition.
 
-   Power on the given bulb(s).
+   :param int transition: Optional time in ms it will take for the bulb to turn
+                          off.
 
-.. function:: power_toggle(target)
+.. function:: power_on(target[, transition])
 
-   Power on (if they are off) or power off (if they are on) the given bulb(s).
+   Power on the given bulb(s) with an optional transition.
+
+   :param int transition: Optional time in ms it will take for the bulb to turn
+                          on.
+
+.. function:: power_toggle(target[, transition])
+
+   Power on (if they are off) or power off (if they are on) the given bulb(s)
+   with an optional transition.
+
+   :param int transition: Optional time in ms it will take for the bulb to turn
+                          on off.
 
 .. function:: set_light_from_hsbk(target, hue, saturation, brightness, kelvin[, transition])
 
diff --git a/effects/CMakeLists.txt b/effects/CMakeLists.txt
new file mode 100644
--- /dev/null
+++ b/effects/CMakeLists.txt
@@ -0,0 +1,11 @@
+INCLUDE_DIRECTORIES(
+    ${CMAKE_CURRENT_SOURCE_DIR}/../
+    ${CMAKE_CURRENT_SOURCE_DIR}
+    ${CMAKE_CURRENT_BINARY_DIR}/../
+    ${CMAKE_CURRENT_BINARY_DIR}
+)
+
+ADD_LIBRARY(
+    effects
+    power_transition.c
+)
diff --git a/effects/power_transition.c b/effects/power_transition.c
new file mode 100644
--- /dev/null
+++ b/effects/power_transition.c
@@ -0,0 +1,343 @@
+// Copyright (c) 2015, Louis Opter <kalessin@kalessin.fr>
+//
+// This file is part of lighstd.
+//
+// lighstd is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// lighstd is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with lighstd.  If not, see <http://www.gnu.org/licenses/>.
+
+#include <sys/queue.h>
+#include <sys/tree.h>
+#include <assert.h>
+#include <endian.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+
+#include <event2/event.h>
+
+#include "core/jsmn.h"
+#include "core/jsonrpc.h"
+#include "core/client.h"
+#include "core/time_monotonic.h"
+#include "core/timer.h"
+#include "lifx/wire_proto.h"
+#include "lifx/bulb.h"
+#include "core/proto.h"
+#include "core/router.h"
+#include "core/effect.h"
+#include "core/lightsd.h"
+#include "power_transition.h"
+
+static void
+lgtd_effect_power_transition_clear_target_list(
+                    struct lgtd_effect_power_transition_target_list *targets)
+{
+    assert(targets);
+
+    while (!SLIST_EMPTY(targets)) {
+        struct lgtd_effect_power_transition_target *target;
+        target = SLIST_FIRST(targets);
+        SLIST_REMOVE_HEAD(targets, link);
+        free(target);
+    }
+}
+
+static void
+lgtd_effect_power_transition_stop_callback(const struct lgtd_effect *effect)
+{
+    assert(effect);
+
+    struct lgtd_effect_power_transition_ctx *ctx = effect->ctx.as_ptr;
+    lgtd_effect_power_transition_clear_target_list(&ctx->targets);
+    free(ctx);
+}
+
+
+static struct lgtd_lifx_bulb *
+lgtd_effect_power_transition_possibly_get_bulb(uint8_t *device_id)
+{
+    assert(device_id);
+
+    struct lgtd_lifx_bulb *bulb = lgtd_lifx_bulb_get(device_id);
+    if (!bulb) {
+        char addr[LGTD_LIFX_ADDR_STRLEN];
+        lgtd_warnx(
+            "bulb %s is unavailable: can't apply a power transition on it",
+            LGTD_IEEE8023MACTOA(device_id, addr)
+        );
+    }
+    return bulb;
+}
+
+static void
+lgtd_effect_power_transition_off_duration_callback(
+                    const struct lgtd_effect *effect)
+{
+    assert(effect);
+
+    lgtd_info(
+        "now = %ju, effect->ends_at=%ju",
+        (uintmax_t)lgtd_time_monotonic_msecs(), (uintmax_t)effect->ends_at
+    );
+
+    struct lgtd_effect_power_transition_ctx *ctx = effect->ctx.as_ptr;
+
+    struct lgtd_effect_power_transition_target *target;
+    SLIST_FOREACH(target, &ctx->targets, link) {
+        struct lgtd_lifx_bulb *bulb;
+        uint8_t *device_id = target->device_id;
+        bulb = lgtd_effect_power_transition_possibly_get_bulb(device_id);
+        if (bulb) { // restore the original brightness
+            struct lgtd_lifx_packet_light_color pkt_light_color = {
+                .hue = bulb->state.hue,
+                .saturation = bulb->state.saturation,
+                .brightness = target->initial_brightness,
+                .kelvin = bulb->state.kelvin
+            };
+            lgtd_lifx_wire_encode_light_color(&pkt_light_color);
+            lgtd_router_send_to_device(
+                bulb, LGTD_LIFX_SET_LIGHT_COLOR, &pkt_light_color
+            );
+        }
+    }
+}
+
+static void
+lgtd_effect_power_transition_off_set_brightness_to_zero(
+                    struct lgtd_effect_power_transition_target_list *targets,
+                    int duration)
+{
+    assert(targets);
+
+    struct lgtd_effect_power_transition_target *target;
+    SLIST_FOREACH(target, targets, link) {
+        // we're using LGTD_TIMER_ACTIVATE_NOW so we'll go through the event
+        // loop again which means we must check that the bulb is still there,
+        // even though this is gonna be executed right away to avoid any race
+        // condition:
+        struct lgtd_lifx_bulb *bulb;
+        uint8_t *device_id = target->device_id;
+        bulb = lgtd_effect_power_transition_possibly_get_bulb(device_id);
+        if (bulb) {
+            struct lgtd_lifx_packet_light_color pkt = {
+                .hue = bulb->state.hue,
+                .saturation = bulb->state.saturation,
+                .brightness = 0,
+                .kelvin = bulb->state.kelvin,
+                .transition = duration
+            };
+            lgtd_lifx_wire_encode_light_color(&pkt);
+            lgtd_router_send_to_device(bulb, LGTD_LIFX_SET_LIGHT_COLOR, &pkt);
+        }
+    }
+}
+
+static void
+lgtd_effect_power_transition_off_power_off(
+                    struct lgtd_effect_power_transition_target_list *targets)
+{
+    assert(targets);
+
+    struct lgtd_effect_power_transition_target *target;
+    SLIST_FOREACH(target, targets, link) {
+        struct lgtd_lifx_bulb *bulb;
+        uint8_t *device_id = target->device_id;
+        bulb = lgtd_effect_power_transition_possibly_get_bulb(device_id);
+        if (bulb) {
+            struct lgtd_lifx_packet_power pkt = {
+                .power = LGTD_LIFX_POWER_OFF
+            };
+            lgtd_router_send_to_device(bulb, LGTD_LIFX_SET_POWER_STATE, &pkt);
+        }
+    }
+}
+
+static enum lgtd_effect_flags
+lgtd_effect_power_transition_off_apply_callback(const struct lgtd_effect *effect)
+{
+    assert(effect);
+
+    struct lgtd_effect_power_transition_ctx *ctx = effect->ctx.as_ptr;
+
+    lgtd_time_mono_t now = lgtd_time_monotonic_msecs();
+    lgtd_info(
+        "now = %ju, next_tick = %ju, ends_at=%ju, apply_cnt=%d/%d",
+        (uintmax_t)now, (uintmax_t)now + LGTD_EFFECT_POWER_TRANSITION_TIMER_MS,
+        (uintmax_t)ctx->ends_at, effect->apply_cnt, ctx->periods
+    );
+
+    if (effect->apply_cnt >= ctx->periods) {
+        lgtd_effect_power_transition_off_power_off(&ctx->targets);
+    } else {
+        lgtd_effect_power_transition_off_set_brightness_to_zero(
+            &ctx->targets, ctx->ends_at - lgtd_time_monotonic_msecs()
+        );
+    }
+
+    return LGTD_EFFECT_CONTINUE;
+}
+
+static enum lgtd_effect_flags
+lgtd_effect_power_transition_on_apply_callback(const struct lgtd_effect *effect)
+{
+    assert(effect);
+
+    struct lgtd_effect_power_transition_ctx *ctx = effect->ctx.as_ptr;
+    uint32_t transition = LGTD_MIN(
+        UINT32_MAX, ctx->ends_at - lgtd_time_monotonic_msecs()
+    );
+
+    lgtd_time_mono_t now = lgtd_time_monotonic_msecs();
+    lgtd_info(
+        "now = %ju, next_tick = %ju, ends_at=%ju",
+        (uintmax_t)now, (uintmax_t)now + LGTD_EFFECT_POWER_TRANSITION_TIMER_MS,
+        (uintmax_t)ctx->ends_at
+    );
+
+    struct lgtd_effect_power_transition_target *target;
+    SLIST_FOREACH(target, &ctx->targets, link) {
+        struct lgtd_lifx_bulb *bulb;
+        uint8_t *device_id = target->device_id;
+        bulb = lgtd_effect_power_transition_possibly_get_bulb(device_id);
+        if (!bulb) {
+            continue;
+        }
+
+        if (!bulb->expected_power_on
+            && bulb->state.power == LGTD_LIFX_POWER_OFF) {
+            // only set the brightness to 0 if the bulbs is off at the
+            // beginning of the transition, otherwise it's gonna flicker:
+            struct lgtd_lifx_packet_light_color pkt_set_brightness_to_zero = {
+                .hue = bulb->state.hue,
+                .saturation = bulb->state.saturation,
+                .brightness = 0,
+                .kelvin = bulb->state.kelvin
+            };
+            lgtd_lifx_wire_encode_light_color(&pkt_set_brightness_to_zero);
+            lgtd_router_send_to_device(
+                bulb, LGTD_LIFX_SET_LIGHT_COLOR, &pkt_set_brightness_to_zero
+            );
+
+            struct lgtd_lifx_packet_power pkt_power_on = {
+                .power = LGTD_LIFX_POWER_ON
+            };
+            lgtd_router_send_to_device(
+                bulb, LGTD_LIFX_SET_POWER_STATE, &pkt_power_on
+            );
+        }
+
+        struct lgtd_lifx_packet_light_color pkt_fade_out = {
+            .hue = bulb->state.hue,
+            .saturation = bulb->state.saturation,
+            .brightness = target->initial_brightness,
+            .kelvin = bulb->state.kelvin,
+            .transition = transition,
+        };
+        lgtd_lifx_wire_encode_light_color(&pkt_fade_out);
+        lgtd_router_send_to_device(
+            bulb, LGTD_LIFX_SET_LIGHT_COLOR, &pkt_fade_out
+        );
+    }
+
+    return LGTD_EFFECT_CONTINUE;
+}
+
+const struct lgtd_effect *
+lgtd_effect_power_transition(const struct lgtd_proto_target_list *targets,
+                             enum lgtd_effect_power_transition_type state,
+                             int duration)
+{
+    assert(targets);
+    assert(duration >= 0);
+
+    // prepare the list of targets and store it into the effect context:
+    struct lgtd_effect_power_transition_ctx *ctx = NULL;
+    struct lgtd_router_device_list *devices = NULL;
+    ctx = calloc(1, sizeof(*ctx));
+    if (!ctx) {
+        goto error;
+    }
+    devices = lgtd_router_targets_to_devices(targets);
+    if (!devices) {
+        goto error;
+    }
+    while (!SLIST_EMPTY(devices)) {
+        struct lgtd_router_device *device = SLIST_FIRST(devices);
+        struct lgtd_lifx_bulb *bulb = device->device;
+
+        struct lgtd_effect_power_transition_target *target;
+        target = calloc(1, sizeof(*target));
+        if (!target) {
+            goto error;
+        }
+        memcpy(target->device_id, bulb->addr, sizeof(target->device_id));
+        target->initial_brightness = bulb->state.brightness;
+        SLIST_INSERT_HEAD(&ctx->targets, target, link);
+
+        SLIST_REMOVE_HEAD(devices, link);
+        free(device);
+    }
+    free(devices);
+    devices = NULL; // let's not free that again if effect_start returns NULL
+
+    // setup the effect:
+    const char *name = NULL;
+    void (*duration_cb)(const struct lgtd_effect *) = NULL;
+    enum lgtd_effect_flags (*apply_cb)(const struct lgtd_effect *) = NULL;
+    int timer_ms = LGTD_EFFECT_POWER_TRANSITION_TIMER_MS;
+    ctx->periods = duration / timer_ms;
+    // align the timer with the duration:
+    timer_ms += (duration % timer_ms) / ctx->periods;
+    // save the duration before it gets skewed for the power off transition:
+    ctx->ends_at = duration;
+    if (state == LGTD_EFFECT_POWER_TRANSITION_OFF) {
+        name = "power_transition[off]";
+        apply_cb = lgtd_effect_power_transition_off_apply_callback;
+        duration +=
+            LGTD_EFFECT_POWER_TRANSITION_OFF_RESTORE_LIGHT_COLOR_DELAY_MSECS;
+        duration_cb = lgtd_effect_power_transition_off_duration_callback;
+    } else {
+        name = "power_transition[on]";
+        apply_cb = lgtd_effect_power_transition_on_apply_callback;
+    }
+    union lgtd_effect_ctx effect_ctx = { .as_ptr = ctx };
+    const struct lgtd_effect *effect = lgtd_effect_start(
+        name,
+        duration,
+        duration_cb,
+        LGTD_TIMER_ACTIVATE_NOW|LGTD_TIMER_PERSISTENT,
+        timer_ms,
+        apply_cb,
+        lgtd_effect_power_transition_stop_callback,
+        effect_ctx
+    );
+    if (effect) {
+        ctx->ends_at += effect->created_at;
+        return effect;
+    }
+
+error:
+    lgtd_warn(
+        "can't start effect power_transition[%s]",
+        state == LGTD_EFFECT_POWER_TRANSITION_OFF ? "off" : "on"
+    );
+    if (ctx) {
+        lgtd_effect_power_transition_clear_target_list(&ctx->targets);
+    }
+    if (devices) {
+        lgtd_router_device_list_free(devices);
+    }
+    return NULL;
+}
diff --git a/effects/power_transition.h b/effects/power_transition.h
new file mode 100644
--- /dev/null
+++ b/effects/power_transition.h
@@ -0,0 +1,58 @@
+// Copyright (c) 2015, Louis Opter <kalessin@kalessin.fr>
+//
+// This file is part of lighstd.
+//
+// lighstd is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// lighstd is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with lighstd.  If not, see <http://www.gnu.org/licenses/>.
+
+#pragma once
+
+enum lgtd_effect_power_transition_type {
+    LGTD_EFFECT_POWER_TRANSITION_OFF,
+    LGTD_EFFECT_POWER_TRANSITION_ON,
+};
+
+// if you power off a bulb and set its color right away you'll see the bulb
+// flicker, so let's delay the restore for a bit once the transition is done:
+enum {
+    // the feels: http://diablo2.judgehype.com/screenshots/presentation/Erreursvf/baguette-magiques-en-os.jpg
+    LGTD_EFFECT_POWER_TRANSITION_OFF_RESTORE_LIGHT_COLOR_DELAY_MSECS = 300
+};
+
+enum { LGTD_EFFECT_POWER_TRANSITION_TIMER_MS = 250 };
+
+struct lgtd_effect_power_transition_target {
+    SLIST_ENTRY(lgtd_effect_power_transition_target)    link;
+    uint8_t                                             device_id[LGTD_LIFX_ADDR_LENGTH];
+    uint16_t                                            initial_brightness;
+};
+SLIST_HEAD(
+    lgtd_effect_power_transition_target_list,
+    lgtd_effect_power_transition_target
+);
+
+#pragma GCC diagnostic push
+#pragma GCC diagnostic warning "-Wpadded"
+struct lgtd_effect_power_transition_ctx {
+    struct lgtd_effect_power_transition_target_list targets;
+    // This doesn't include the delay to restore the light color after a
+    // power off:
+    lgtd_time_mono_t                                ends_at;
+    // how many times the apply_cb will be called
+    int                                             periods;
+};
+#pragma GCC diagnostic pop
+
+const struct lgtd_effect *lgtd_effect_power_transition(const struct lgtd_proto_target_list *,
+                                                       enum lgtd_effect_power_transition_type,
+                                                       int);
diff --git a/examples/lightsc.py b/examples/lightsc.py
--- a/examples/lightsc.py
+++ b/examples/lightsc.py
@@ -199,14 +199,18 @@
             transient=transient
         )
 
-    def power_on(self, target):
-        return self._jsonrpc_call("power_on", {"target": target})
+    def _power_call(self, call, target, transition):
+        args = [target, transition] if transition is not None else [target]
+        self._jsonrpc_call(call, args)
 
-    def power_off(self, target):
-        return self._jsonrpc_call("power_off", {"target": target})
+    def power_on(self, target, transition=None):
+        return self._power_call("power_on", target, transition)
 
-    def power_toggle(self, target):
-        return self._jsonrpc_call("power_toggle", {"target": target})
+    def power_off(self, target, transition=None):
+        return self._power_call("power_off", target, transition)
+
+    def power_toggle(self, target, transition=None):
+        return self._power_call("power_toggle", target, transition)
 
     def get_light_state(self, target):
         return self._jsonrpc_call("get_light_state", [target])
diff --git a/lifx/wire_proto.c b/lifx/wire_proto.c
--- a/lifx/wire_proto.c
+++ b/lifx/wire_proto.c
@@ -205,6 +205,24 @@
         },
         {
             REQUEST_ONLY,
+            // like SET_POWER_STATE but with transition support, not supported
+            // by the earliest versions of the bulbs (Original 1000 fw 1.5 has
+            // it, I don't know which version between 1.1 and 1.5 added it):
+            .name = "SET_POWER",
+            .type = LGTD_LIFX_SET_POWER,
+            .size = sizeof(struct lgtd_lifx_packet_power),
+            .encode = ENCODER(lgtd_lifx_wire_encode_power)
+        },
+        {
+            RESPONSE_ONLY,
+            .name = "STATE_POWER", // same thing as POWER_STATE
+            .type = LGTD_LIFX_STATE_POWER,
+            .size = sizeof(struct lgtd_lifx_packet_power_state),
+            .decode = DECODER(lgtd_lifx_wire_decode_power_state),
+            .handle = HANDLER(lgtd_lifx_gateway_handle_power_state)
+        },
+        {
+            REQUEST_ONLY,
             .name = "SET_TAGS",
             .type = LGTD_LIFX_SET_TAGS,
             .size = sizeof(struct lgtd_lifx_packet_tags),
@@ -559,16 +577,6 @@
         },
         {
             UNIMPLEMENTED,
-            .name = "SET_POWER",
-            .type = LGTD_LIFX_SET_POWER
-        },
-        {
-            UNIMPLEMENTED,
-            .name = "STATE_POWER",
-            .type = LGTD_LIFX_STATE_POWER
-        },
-        {
-            UNIMPLEMENTED,
             .name = "SET_WAVEFORM_OPTIONAL",
             .type = LGTD_LIFX_SET_WAVEFORM_OPTIONAL
         },
@@ -941,8 +949,7 @@
     pkt->brightness = le16toh(pkt->brightness);
     pkt->kelvin = le16toh(pkt->kelvin);
     pkt->dim = le16toh(pkt->dim);
-    // The bulbs actually return power values between 0 and 0xffff, not sure
-    // what the intermediate values mean, let's pull them down to 0:
+    // see comment in lgtd_lifx_wire_decode_power_state:
     if (pkt->power != LGTD_LIFX_POWER_ON) {
         pkt->power = LGTD_LIFX_POWER_OFF;
     }
@@ -954,12 +961,26 @@
 {
     assert(pkt);
 
+    // POWER_STATE does uses the full 0-65535 range and its value isn't related
+    // to the brightness or the power level, but is instead related to the
+    // transition in SET_POWER: if you do SET_POWER with a 4s transition at t 0s
+    // POWER_STATE will be 0 at t 2s StatePower will be 32767 and at t 4s
+    // POWER_STATE will be 65535 not matter what the targeted brightness is.
+    // See GH-5: https://github.com/lopter/lightsd/issues/5
     if (pkt->power != LGTD_LIFX_POWER_ON) {
         pkt->power = LGTD_LIFX_POWER_OFF;
     }
 }
 
 void
+lgtd_lifx_wire_encode_power(struct lgtd_lifx_packet_power *pkt)
+{
+    assert(pkt);
+
+    pkt->transition = htole32(pkt->transition);
+}
+
+void
 lgtd_lifx_wire_encode_light_color(struct lgtd_lifx_packet_light_color *pkt)
 {
     assert(pkt);
diff --git a/lifx/wire_proto.h b/lifx/wire_proto.h
--- a/lifx/wire_proto.h
+++ b/lifx/wire_proto.h
@@ -264,6 +264,11 @@
     LGTD_LIFX_POWER_ON = 0xffff
 };
 
+struct lgtd_lifx_packet_power {
+    uint16_t    power; // see enum lgtd_lifx_power_state
+    uint32le_t  transition; // msecs
+};
+
 struct lgtd_lifx_packet_power_state {
     uint16_t    power; // see enum lgtd_lifx_power_state
 };
@@ -468,6 +473,7 @@
 void lgtd_lifx_wire_decode_light_status(struct lgtd_lifx_packet_light_status *);
 void lgtd_lifx_wire_encode_light_status(struct lgtd_lifx_packet_light_status *);
 void lgtd_lifx_wire_decode_power_state(struct lgtd_lifx_packet_power_state *);
+void lgtd_lifx_wire_encode_power(struct lgtd_lifx_packet_power *);
 
 void lgtd_lifx_wire_encode_light_color(struct lgtd_lifx_packet_light_color *);
 void lgtd_lifx_wire_encode_waveform(struct lgtd_lifx_packet_waveform *);
diff --git a/tests/core/CMakeLists.txt b/tests/core/CMakeLists.txt
--- a/tests/core/CMakeLists.txt
+++ b/tests/core/CMakeLists.txt
@@ -2,10 +2,12 @@
     ${LIGHTSD_SOURCE_DIR}
     ${LIGHTSD_SOURCE_DIR}/core/
     ${CMAKE_CURRENT_SOURCE_DIR}
+    ${CMAKE_CURRENT_SOURCE_DIR}/../
     ${CMAKE_CURRENT_SOURCE_DIR}/../lifx
     ${LIGHTSD_BINARY_DIR}
     ${LIGHTSD_BINARY_DIR}/core/
     ${CMAKE_CURRENT_BINARY_DIR}
+    ${CMAKE_CURRENT_BINARY_DIR}/../
     ${CMAKE_CURRENT_BINARY_DIR}/../lifx
 )
 
diff --git a/tests/core/effect/CMakeLists.txt b/tests/core/effect/CMakeLists.txt
new file mode 100644
--- /dev/null
+++ b/tests/core/effect/CMakeLists.txt
@@ -0,0 +1,13 @@
+INCLUDE_DIRECTORIES(
+    ${CMAKE_CURRENT_SOURCE_DIR}
+    ${CMAKE_CURRENT_BINARY_DIR}
+)
+
+FUNCTION(ADD_EFFECT_TEST TEST_SOURCE)
+    ADD_TEST_FROM_C_SOURCES(${TEST_SOURCE} ${TIME_MONOTONIC_LIBRARY})
+ENDFUNCTION()
+
+FILE(GLOB TESTS RELATIVE ${CMAKE_CURRENT_SOURCE_DIR} "test_*.c")
+FOREACH(TEST ${TESTS})
+    ADD_EFFECT_TEST(${TEST})
+ENDFOREACH()
diff --git a/tests/core/effect/test_effect_duration_callback.c b/tests/core/effect/test_effect_duration_callback.c
new file mode 100644
--- /dev/null
+++ b/tests/core/effect/test_effect_duration_callback.c
@@ -0,0 +1,86 @@
+#include "core/effect.c"
+
+#include "mock_log.h"
+#define MOCKED_LGTD_TIMER_STOP
+#include "mock_timer.h"
+
+struct lgtd_timer *MOCK_DURATION_TIMER = (struct lgtd_timer *)0x2a;
+
+static int timer_stop_call_count = 0;
+
+void
+lgtd_timer_stop(struct lgtd_timer *timer)
+{
+    if (timer != MOCK_DURATION_TIMER) {
+        lgtd_errx(
+            1, "lgtd_timer_stop called with timer %p (expected %p)",
+            timer, MOCK_DURATION_TIMER
+        );
+    }
+
+    timer_stop_call_count++;
+}
+
+static int duration_callback_call_count = 0;
+
+static void
+test_duration_callback(const struct lgtd_effect *effect)
+{
+    if (!effect) {
+        lgtd_errx(1, "test_duration_callback didn't receive an effect");
+    }
+
+    duration_callback_call_count++;
+}
+
+int
+main(void)
+{
+    struct lgtd_effect *effect = NULL;
+    union lgtd_timer_ctx ctx;
+
+    effect = calloc(1, sizeof(*effect));
+    effect->name = "test";
+    effect->duration_cb =  test_duration_callback;
+    LIST_INSERT_HEAD(&lgtd_effects, effect, link);
+    ctx.as_ptr = effect;
+    lgtd_effect_duration_callback(MOCK_DURATION_TIMER, ctx);
+    if (duration_callback_call_count != 1) {
+        lgtd_errx(
+            1, "duration_callback_call_count = %d (expected 1)",
+            duration_callback_call_count
+        );
+    }
+    if (timer_stop_call_count != 1) {
+        lgtd_errx(
+            1, "timer_stop_call_count = %d (expected 1)",
+            timer_stop_call_count
+        );
+    }
+    if (!LIST_EMPTY(&lgtd_effects)) {
+        lgtd_errx(1, "the effects list should be empty");
+    }
+
+    effect = calloc(1, sizeof(*effect));
+    effect->name = "test_without_callback";
+    LIST_INSERT_HEAD(&lgtd_effects, effect, link);
+    ctx.as_ptr = effect;
+    lgtd_effect_duration_callback(MOCK_DURATION_TIMER, ctx);
+    if (duration_callback_call_count != 1) {
+        lgtd_errx(
+            1, "duration_callback_call_count = %d (expected 1)",
+            duration_callback_call_count
+        );
+    }
+    if (timer_stop_call_count != 2) {
+        lgtd_errx(
+            1, "timer_stop_call_count = %d (expected 2)",
+            timer_stop_call_count
+        );
+    }
+    if (!LIST_EMPTY(&lgtd_effects)) {
+        lgtd_errx(1, "the effects list should be empty");
+    }
+
+    return 0;
+}
diff --git a/tests/core/effect/test_effect_start.c b/tests/core/effect/test_effect_start.c
new file mode 100644
--- /dev/null
+++ b/tests/core/effect/test_effect_start.c
@@ -0,0 +1,284 @@
+#include "core/effect.c"
+
+#include "mock_log.h"
+#define MOCKED_LGTD_TIMER_START
+#define MOCKED_LGTD_TIMER_STOP
+#include "mock_timer.h"
+
+enum {
+    FINITE_WITHOUT_CALLBACKS = 0,   // timer_ms == 0, duration != 0
+    FINITE_WITH_CALLBACKS,
+    INSTANT_WITHOUT_CALLBACKS,      // duration == 0
+    INSTANT_WITH_CALLBACKS,         // duration == 0
+    PERIODIC_INFINITE,              // timer_ms != 0, duration == 0
+    PERIODIC_FINITE,                // timer_ms != 0, duration != 0
+    TEST_CASES_COUNT,
+} test_case = FINITE_WITHOUT_CALLBACKS;
+
+static void test_apply_callback(const struct lgtd_effect *);
+static void test_duration_callback(const struct lgtd_effect *);
+
+const struct test_parameters {
+    const char              *name;
+    int                     duration;
+    void                    (*duration_cb)(const struct lgtd_effect *);
+    int                     timer_flags;
+    int                     timer_ms;
+    void                    (*apply_cb)(const struct lgtd_effect *);
+    union lgtd_effect_ctx   ctx;
+    int                     expected_timer_start_calls;
+    int                     expected_duration_callback_calls;
+    int                     expected_apply_callback_calls;
+    bool                    expected_effect;
+} test_cases_parameters[] = {
+    {
+        .name = "FINITE_WITHOUT_CALLBACKS",
+        .duration = 420,
+        .ctx = { .as_uint = 1 },
+        .expected_timer_start_calls = 1,
+        .expected_effect = true,
+    },
+    {
+        .name = "FINITE_WITH_CALLBACKS",
+        .duration = 419,
+        .duration_cb = test_duration_callback,
+        .apply_cb = test_apply_callback,
+        .ctx = { .as_uint = 2 },
+        .expected_timer_start_calls = 1,
+        .expected_apply_callback_calls = 1,
+        .expected_effect = true,
+    },
+    {
+        .name = "INSTANT_WITHOUT_CALLBACKS",
+        .expected_effect = false,
+        .ctx = { .as_uint = 3 },
+    },
+    {
+        .name = "INSTANT_WITH_CALLBACKS",
+        .expected_effect = false,
+        .apply_cb = test_apply_callback,
+        .ctx = { .as_uint = 4 },
+        .duration_cb = test_duration_callback,
+        .expected_apply_callback_calls = 1,
+        .expected_duration_callback_calls = 1,
+    },
+    {
+        .name = "PERIODIC_FINITE",
+        .expected_effect = true,
+        .apply_cb = test_apply_callback,
+        .duration_cb = test_duration_callback,
+        .duration = 370,
+        .timer_ms = 180,
+        .timer_flags = LGTD_TIMER_PERSISTENT,
+        .ctx = { .as_uint = 5 },
+        .expected_timer_start_calls = 2,
+    },
+    {
+        .name = "PERIODIC_INFINITE",
+        .expected_effect = true,
+        .apply_cb = test_apply_callback,
+        .timer_ms = 60,
+        .timer_flags = LGTD_TIMER_PERSISTENT|LGTD_TIMER_ACTIVATE_NOW,
+        .ctx = { .as_uint = 6 },
+        .expected_timer_start_calls = 1,
+    },
+};
+
+
+static int timer_start_call_count = 0;
+
+struct lgtd_timer *
+lgtd_timer_start(int flags,
+                 int ms,
+                 void (*cb)(struct lgtd_timer *,
+                            union lgtd_timer_ctx),
+                 union lgtd_timer_ctx ctx)
+{
+    const struct test_parameters *params = &test_cases_parameters[test_case];
+    if (cb == lgtd_effect_duration_callback) {
+        if (flags != LGTD_TIMER_DEFAULT_FLAGS) {
+            lgtd_errx(
+                1, "lgtd_timer_start called with unexpected flags %#x "
+                "(expected %#x)", flags, LGTD_TIMER_DEFAULT_FLAGS
+            );
+        }
+        if (ms != params->duration) {
+            lgtd_errx(
+                1, "lgtd_timer_start called with unexpected duration %d "
+                "(expected %d)", ms, params->duration
+            );
+        }
+    } else if (cb == lgtd_effect_timer_callback) {
+        if (flags != params->timer_flags) {
+            lgtd_errx(
+                1, "lgtd_timer_start called with unexpected flags %#x "
+                "(expected %#x)", flags, params->timer_flags
+            );
+        }
+        if (ms != params->timer_ms) {
+            lgtd_errx(
+                1, "lgtd_timer_start called with unexpected duration %d "
+                "(expected %d)", ms, params->timer_ms
+            );
+        }
+    } else {
+        lgtd_errx(
+            1, "lgtd_timer_start called with unexpected callback at %p", cb
+        );
+    }
+
+    if (!ctx.as_ptr) {
+        lgtd_errx(1, "lgtd_timer_start called without a callback context");
+    }
+
+    timer_start_call_count++;
+
+    return (struct lgtd_timer *)(intptr_t)timer_start_call_count;
+}
+
+static int timer_stop_call_count = 0;
+
+void
+lgtd_timer_stop(struct lgtd_timer *timer)
+{
+    if (!timer) {
+        lgtd_errx(1, "lgtd_timer_stop called without a timer");
+    }
+
+    timer_stop_call_count++;
+}
+
+static int apply_callback_call_count = 0;
+
+static void
+test_apply_callback(const struct lgtd_effect *effect)
+{
+    if (!effect) {
+        lgtd_errx(1, "test_apply_callback didn't receive an effect");
+    }
+
+    const struct test_parameters *params = &test_cases_parameters[test_case];
+    if (params->name != effect->name) {
+        lgtd_errx(
+            1, "test_apply_callback got effect %s (expected %s)",
+            effect->name, params->name
+        );
+    }
+
+    apply_callback_call_count++;
+}
+
+static int duration_callback_call_count = 0;
+
+static void
+test_duration_callback(const struct lgtd_effect *effect)
+{
+    if (!effect) {
+        lgtd_errx(1, "test_duration_callback didn't receive an effect");
+    }
+
+    const struct test_parameters *params = &test_cases_parameters[test_case];
+    if (params->name != effect->name) {
+        lgtd_errx(
+            1, "test_duration_callback got effect %s (expected %s)",
+            effect->name, params->name
+        );
+    }
+    duration_callback_call_count++;
+}
+
+static void
+reset_call_counts(void)
+{
+    timer_start_call_count = 0;
+    timer_stop_call_count = 0;
+    apply_callback_call_count = 0;
+    duration_callback_call_count = 0;
+}
+
+int
+main(void)
+{
+#define CHECK_CALL_COUNT(cnt_name) do {                         \
+    int call_count = cnt_name ## _call_count;                   \
+    int expectation = params->expected_ ## cnt_name ## _calls;  \
+    if (call_count != expectation) {                            \
+        lgtd_errx(                                              \
+            1, # cnt_name " = %d (expected) %d",                \
+            call_count, expectation                             \
+        );                                                      \
+    }                                                           \
+} while (0)
+
+    lgtd_time_mono_t now = lgtd_time_monotonic_msecs();
+
+    for (; test_case != TEST_CASES_COUNT; test_case++) {
+        reset_call_counts();
+
+        const struct test_parameters *params;
+        params = &test_cases_parameters[test_case];
+        lgtd_info("--- test case: %s ---", params->name);
+        const struct lgtd_effect *effect = lgtd_effect_start(
+            params->name,
+            params->duration, params->duration_cb,
+            params->timer_flags, params->timer_ms, params->apply_cb,
+            params->ctx
+        );
+        if (!effect && params->expected_effect) {
+            lgtd_errx(1, "lgtd_effect_start didn't return an effect object");
+            if (LIST_FIRST(&lgtd_effects) != effect) {
+                lgtd_errx(
+                    1, "lgtd_effect_start didn't insert the new effect at the "
+                    "head of the effects list"
+                );
+            }
+            if (effect->created_at < now) {
+                lgtd_errx(
+                    1, "effect->created_at = %ju (expected >= %ju)",
+                    (uintmax_t)effect->created_at, (uintmax_t)now
+                );
+            }
+            if (effect->duration != params->duration) {
+                lgtd_errx(
+                    1, "effect->duration = %d (expected %d)",
+                    effect->duration, params->duration
+                );
+            }
+            if (!effect->name || strcmp(effect->name, params->name)) {
+                lgtd_errx(
+                    1, "effect->name = %s (expected %s)",
+                    effect->name, params->name
+                );
+            }
+            if (effect->apply_cb != params->apply_cb) {
+                lgtd_errx(
+                    1, "effect->apply_cb = %p (expected %p)",
+                    effect->apply_cb, params->apply_cb
+                );
+            }
+            if (effect->duration_cb != params->duration_cb) {
+                lgtd_errx(
+                    1, "effect->duration_cb = %p (expected %p)",
+                    effect->duration_cb, params->duration_cb
+                );
+            }
+            if (effect->apply_cnt != 0) {
+                lgtd_errx(
+                    1, "effect->apply_cnt = %u (expected 0)", effect->apply_cnt
+                );
+            }
+            if (memcmp(&effect->ctx, &params->ctx, sizeof(effect->ctx))) {
+                lgtd_errx(1, "unexpected effect->ctx");
+            }
+        }
+        if (effect && !params->expected_effect) {
+            lgtd_errx(1, "lgtd_effect_start returned an effect object");
+        }
+        CHECK_CALL_COUNT(timer_start);
+        CHECK_CALL_COUNT(apply_callback);
+        CHECK_CALL_COUNT(duration_callback);
+        lgtd_info("--- end ---");
+    }
+
+    return 0;
+}
diff --git a/tests/core/effect/test_effect_stop.c b/tests/core/effect/test_effect_stop.c
new file mode 100644
--- /dev/null
+++ b/tests/core/effect/test_effect_stop.c
@@ -0,0 +1,61 @@
+#include "core/effect.c"
+
+#include "mock_log.h"
+#define MOCKED_LGTD_TIMER_STOP
+#include "mock_timer.h"
+
+struct lgtd_timer *MOCK_TIMER = (struct lgtd_timer *)0x2a;
+
+static int timer_stop_call_count = 0;
+
+void
+lgtd_timer_stop(struct lgtd_timer *timer)
+{
+    if (timer != MOCK_TIMER) {
+        lgtd_errx(
+            1, "lgtd_timer_stop called with timer %p (expected %p)",
+            timer, MOCK_TIMER
+        );
+    }
+
+    timer_stop_call_count++;
+}
+
+int
+main(void)
+{
+    struct lgtd_effect *effect;
+    
+    effect = calloc(1, sizeof(*effect));
+    effect->name = "test";
+    effect->timer = MOCK_TIMER;
+    LIST_INSERT_HEAD(&lgtd_effects, effect, link);
+    lgtd_effect_stop(effect);
+    if (!LIST_EMPTY(&lgtd_effects)) {
+        lgtd_errx(
+            1, "lgtd_effect_stop didn't remove the effect from the effects list"
+        );
+    }
+    if (timer_stop_call_count != 1) {
+        lgtd_errx(
+            1, "timer_stop_call_count = %d (expected 1)", timer_stop_call_count
+        );
+    }
+
+    effect = calloc(1, sizeof(*effect));
+    effect->name = "test_without_timer";
+    LIST_INSERT_HEAD(&lgtd_effects, effect, link);
+    lgtd_effect_stop(effect);
+    if (!LIST_EMPTY(&lgtd_effects)) {
+        lgtd_errx(
+            1, "lgtd_effect_stop didn't remove the effect from the effects list"
+        );
+    }
+    if (timer_stop_call_count != 1) {
+        lgtd_errx(
+            1, "timer_stop_call_count = %d (expected 1)", timer_stop_call_count
+        );
+    }
+
+    return 0;
+}
diff --git a/tests/core/effect/test_effect_timer_callback.c b/tests/core/effect/test_effect_timer_callback.c
new file mode 100644
--- /dev/null
+++ b/tests/core/effect/test_effect_timer_callback.c
@@ -0,0 +1,67 @@
+#include "core/effect.c"
+
+#include "mock_log.h"
+#include "mock_timer.h"
+
+static int apply_callback_call_count = 0;
+
+static void
+test_apply_callback(const struct lgtd_effect *effect)
+{
+    if (!effect) {
+        lgtd_errx(1, "test_apply_callback didn't receive an effect");
+    }
+
+    apply_callback_call_count++;
+}
+
+int
+main(void)
+{
+    struct lgtd_effect *effect = NULL;
+    union lgtd_timer_ctx ctx;
+    lgtd_time_mono_t now = lgtd_time_monotonic_msecs();
+
+    effect = calloc(1, sizeof(*effect));
+    effect->name = "test";
+    effect->created_at = now;
+    effect->duration = 420;
+    effect->apply_cb =  test_apply_callback;
+    LIST_INSERT_HEAD(&lgtd_effects, effect, link);
+
+    ctx.as_ptr = effect;
+    lgtd_effect_timer_callback(NULL, ctx);
+    if (apply_callback_call_count != 1) {
+        lgtd_errx(
+            1, "apply_callback_call_count = %d (expected 1)",
+            apply_callback_call_count
+        );
+    }
+    if (effect->apply_cnt != 1) {
+        lgtd_errx(
+            1, "effect->apply_cnt = %u (expected 1)",
+            apply_callback_call_count
+        );
+    }
+
+    // make it stale (call time > effect end)
+    effect->created_at = now - effect->duration * 2;
+    lgtd_effect_timer_callback(NULL, ctx);
+    if (apply_callback_call_count != 1) {
+        lgtd_errx(
+            1, "apply_callback_call_count = %d (expected 1)",
+            apply_callback_call_count
+        );
+    }
+    if (effect->apply_cnt != 1) {
+        lgtd_errx(
+            1, "effect->apply_cnt = %u (expected 1)",
+            apply_callback_call_count
+        );
+    }
+    if (!LIST_EMPTY(&lgtd_effects)) {
+        lgtd_errx(1, "stale effect not stopped"); 
+    }
+
+    return 0;
+}
diff --git a/tests/core/jsonrpc/test_jsonrpc_batch.c b/tests/core/jsonrpc/test_jsonrpc_batch.c
--- a/tests/core/jsonrpc/test_jsonrpc_batch.c
+++ b/tests/core/jsonrpc/test_jsonrpc_batch.c
@@ -12,12 +12,17 @@
 
 void
 lgtd_proto_power_on(struct lgtd_client *client,
-                    const struct lgtd_proto_target_list *targets)
+                    const struct lgtd_proto_target_list *targets,
+                    int transition)
 {
     if (!client) {
         errx(1, "missing client!");
     }
 
+    if (transition) {
+        errx(1, "transition=%d (expected 0)", transition);
+    }
+
     if (strcmp(SLIST_FIRST(targets)->target, "*")) {
         errx(
             1, "Invalid target [%s] (expected=[*])",
diff --git a/tests/core/jsonrpc/test_jsonrpc_batch_notifications_only.c b/tests/core/jsonrpc/test_jsonrpc_batch_notifications_only.c
--- a/tests/core/jsonrpc/test_jsonrpc_batch_notifications_only.c
+++ b/tests/core/jsonrpc/test_jsonrpc_batch_notifications_only.c
@@ -12,12 +12,17 @@
 
 void
 lgtd_proto_power_on(struct lgtd_client *client,
-                    const struct lgtd_proto_target_list *targets)
+                    const struct lgtd_proto_target_list *targets,
+                    int transition)
 {
     if (!client) {
         errx(1, "missing client!");
     }
 
+    if (transition) {
+        errx(1, "proto_power_on got transition=%d (expected 0)", transition);
+    }
+
     if (strcmp(SLIST_FIRST(targets)->target, "*")) {
         errx(
             1, "Invalid target [%s] (expected=[*])",
diff --git a/tests/core/jsonrpc/test_jsonrpc_batch_one_invalid_request.c b/tests/core/jsonrpc/test_jsonrpc_batch_one_invalid_request.c
--- a/tests/core/jsonrpc/test_jsonrpc_batch_one_invalid_request.c
+++ b/tests/core/jsonrpc/test_jsonrpc_batch_one_invalid_request.c
@@ -11,12 +11,17 @@
 
 void
 lgtd_proto_power_on(struct lgtd_client *client,
-                    const struct lgtd_proto_target_list *targets)
+                    const struct lgtd_proto_target_list *targets,
+                    int transition)
 {
     if (!client) {
         errx(1, "missing client!");
     }
 
+    if (transition) {
+        errx(1, "proto_power_on got transition=%d (expected 0)", transition);
+    }
+
     if (strcmp(SLIST_FIRST(targets)->target, "*")) {
         errx(
             1, "Invalid target [%s] (expected=[*])",
diff --git a/tests/core/jsonrpc/test_jsonrpc_batch_one_notification.c b/tests/core/jsonrpc/test_jsonrpc_batch_one_notification.c
--- a/tests/core/jsonrpc/test_jsonrpc_batch_one_notification.c
+++ b/tests/core/jsonrpc/test_jsonrpc_batch_one_notification.c
@@ -12,12 +12,17 @@
 
 void
 lgtd_proto_power_on(struct lgtd_client *client,
-                    const struct lgtd_proto_target_list *targets)
+                    const struct lgtd_proto_target_list *targets,
+                    int transition)
 {
     if (!client) {
         errx(1, "missing client!");
     }
 
+    if (transition) {
+        errx(1, "proto_power_on got transition=%d (expected 0)", transition);
+    }
+
     if (strcmp(SLIST_FIRST(targets)->target, "*")) {
         errx(
             1, "Invalid target [%s] (expected=[*])",
diff --git a/tests/core/jsonrpc/test_jsonrpc_check_and_call_power_off.c b/tests/core/jsonrpc/test_jsonrpc_check_and_call_power_off.c
--- a/tests/core/jsonrpc/test_jsonrpc_check_and_call_power_off.c
+++ b/tests/core/jsonrpc/test_jsonrpc_check_and_call_power_off.c
@@ -12,12 +12,17 @@
 
 void
 lgtd_proto_power_off(struct lgtd_client *client,
-                     const struct lgtd_proto_target_list *targets)
+                     const struct lgtd_proto_target_list *targets,
+                     int transition)
 {
     if (!client) {
         errx(1, "missing client!");
     }
 
+    if (transition != 420) {
+        errx(1, "Invalid transition=%d (expected 420)", transition);
+    }
+
     if (strcmp(SLIST_FIRST(targets)->target, "*")) {
         errx(
             1, "Invalid target [%s] (expected=[*])",
@@ -34,7 +39,7 @@
     const char json[] = ("{"
         "\"jsonrpc\": \"2.0\","
         "\"method\": \"power_off\","
-        "\"params\": {\"target\": \"*\"},"
+        "\"params\": {\"target\": \"*\", \"transition\": 420},"
         "\"id\": \"42\""
     "}");
     int parsed = parse_json(
diff --git a/tests/core/jsonrpc/test_jsonrpc_check_and_call_power_off_missing_target.c b/tests/core/jsonrpc/test_jsonrpc_check_and_call_power_off_missing_target.c
--- a/tests/core/jsonrpc/test_jsonrpc_check_and_call_power_off_missing_target.c
+++ b/tests/core/jsonrpc/test_jsonrpc_check_and_call_power_off_missing_target.c
@@ -12,10 +12,12 @@
 
 void
 lgtd_proto_power_off(struct lgtd_client *client,
-                     const struct lgtd_proto_target_list *targets)
+                     const struct lgtd_proto_target_list *targets,
+                     int transition)
 {
     (void)targets;
     (void)client;
+    (void)transition;
     power_off_called = true;
 }
 
diff --git a/tests/core/jsonrpc/test_jsonrpc_check_and_call_power_on.c b/tests/core/jsonrpc/test_jsonrpc_check_and_call_power_on.c
--- a/tests/core/jsonrpc/test_jsonrpc_check_and_call_power_on.c
+++ b/tests/core/jsonrpc/test_jsonrpc_check_and_call_power_on.c
@@ -12,12 +12,17 @@
 
 void
 lgtd_proto_power_on(struct lgtd_client *client,
-                    const struct lgtd_proto_target_list *targets)
+                    const struct lgtd_proto_target_list *targets,
+                    int transition)
 {
     if (!client) {
         errx(1, "missing client!");
     }
 
+    if (transition != 420) {
+        errx(1, "Invalid transition=%d (expected 420)", transition);
+    }
+
     if (strcmp(SLIST_FIRST(targets)->target, "*")) {
         errx(
             1, "Invalid target [%s] (expected=[*])",
@@ -34,7 +39,7 @@
     const char json[] = ("{"
         "\"jsonrpc\": \"2.0\","
         "\"method\": \"power_on\","
-        "\"params\": {\"target\": \"*\"},"
+        "\"params\": {\"target\": \"*\", \"transition\": 420},"
         "\"id\": \"42\""
     "}");
     int parsed = parse_json(
diff --git a/tests/core/jsonrpc/test_jsonrpc_check_and_call_power_on_missing_target.c b/tests/core/jsonrpc/test_jsonrpc_check_and_call_power_on_missing_target.c
--- a/tests/core/jsonrpc/test_jsonrpc_check_and_call_power_on_missing_target.c
+++ b/tests/core/jsonrpc/test_jsonrpc_check_and_call_power_on_missing_target.c
@@ -12,10 +12,12 @@
 
 void
 lgtd_proto_power_on(struct lgtd_client *client,
-                    const struct lgtd_proto_target_list *targets)
+                    const struct lgtd_proto_target_list *targets,
+                    int transition)
 {
     (void)client;
     (void)targets;
+    (void)transition;
     power_on_called = true;
 }
 
diff --git a/tests/core/jsonrpc/test_jsonrpc_check_and_call_power_toggle.c b/tests/core/jsonrpc/test_jsonrpc_check_and_call_power_toggle.c
--- a/tests/core/jsonrpc/test_jsonrpc_check_and_call_power_toggle.c
+++ b/tests/core/jsonrpc/test_jsonrpc_check_and_call_power_toggle.c
@@ -12,12 +12,17 @@
 
 void
 lgtd_proto_power_toggle(struct lgtd_client *client,
-                        const struct lgtd_proto_target_list *targets)
+                        const struct lgtd_proto_target_list *targets,
+                        int transition)
 {
     if (!client) {
         errx(1, "missing client!");
     }
 
+    if (transition != 420) {
+        errx(1, "Invalid transition=%d (expected 420)", transition);
+    }
+
     if (strcmp(SLIST_FIRST(targets)->target, "*")) {
         errx(
             1, "Invalid target [%s] (expected=[*])",
@@ -34,7 +39,7 @@
     const char json[] = ("{"
         "\"jsonrpc\": \"2.0\","
         "\"method\": \"power_toggle\","
-        "\"params\": {\"target\": \"*\"},"
+        "\"params\": {\"target\": \"*\", \"transition\": 420},"
         "\"id\": \"42\""
     "}");
     int parsed = parse_json(
diff --git a/tests/core/jsonrpc/test_jsonrpc_dispatch_one_no_params.c b/tests/core/jsonrpc/test_jsonrpc_dispatch_one_no_params.c
--- a/tests/core/jsonrpc/test_jsonrpc_dispatch_one_no_params.c
+++ b/tests/core/jsonrpc/test_jsonrpc_dispatch_one_no_params.c
@@ -10,10 +10,12 @@
 
 void
 lgtd_proto_power_on(struct lgtd_client *client,
-                    const struct lgtd_proto_target_list *targets)
+                    const struct lgtd_proto_target_list *targets,
+                    int transition)
 {
     (void)client;
     (void)targets;
+    (void)transition;
 
     errx(1, "lgtd_proto_power_on shouldn't have been called");
 }
diff --git a/tests/core/jsonrpc/test_jsonrpc_extract_values_from_schema_and_array_honors_objsize.c b/tests/core/jsonrpc/test_jsonrpc_extract_values_from_schema_and_array_honors_objsize.c
--- a/tests/core/jsonrpc/test_jsonrpc_extract_values_from_schema_and_array_honors_objsize.c
+++ b/tests/core/jsonrpc/test_jsonrpc_extract_values_from_schema_and_array_honors_objsize.c
@@ -28,7 +28,7 @@
             "target",
             offsetof(struct lgtd_jsonrpc_target_args, target),
             offsetof(struct lgtd_jsonrpc_target_args, target_ntokens),
-            lgtd_jsonrpc_type_string_number_or_array,
+            lgtd_jsonrpc_type_string_integer_or_array,
             false
         ),
         LGTD_JSONRPC_NODE(
@@ -37,7 +37,7 @@
             -1,
             // this must dereference json from the what's in the token (see
             // next comment):
-            lgtd_jsonrpc_type_number,
+            lgtd_jsonrpc_type_integer,
             false
         )
     };
diff --git a/tests/core/mock_effect.h b/tests/core/mock_effect.h
new file mode 100644
--- /dev/null
+++ b/tests/core/mock_effect.h
@@ -0,0 +1,40 @@
+#pragma once
+
+#include "core/effect.h" // to pull the union definition
+
+#ifndef MOCKED_LGTD_EFFECT_STOP
+void
+lgtd_effect_stop(struct lgtd_effect *effect)
+{
+    (void)effect;
+}
+#endif
+
+#ifndef MOCKED_LGTD_EFFECT_STOP_ALL
+void
+lgtd_effect_stop_all(void)
+{
+}
+#endif
+
+#ifndef MOCKED_LGTD_EFFECT_START
+struct lgtd_effect *
+lgtd_effect_start(const char *name,
+                  int duration,
+                  void (*duration_cb)(const struct lgtd_effect *),
+                  int timer_flags,
+                  int timer_ms,
+                  void (*apply_cb)(const struct lgtd_effect *),
+                  union lgtd_effect_ctx ctx)
+{
+    (void)name;
+    (void)duration;
+    (void)duration_cb;
+    (void)timer_flags;
+    (void)timer_ms;
+    (void)apply_cb;
+    (void)ctx;
+
+    return NULL;
+}
+#endif
diff --git a/tests/core/mock_proto.h b/tests/core/mock_proto.h
--- a/tests/core/mock_proto.h
+++ b/tests/core/mock_proto.h
@@ -35,20 +35,36 @@
 #ifndef MOCKED_LGTD_PROTO_POWER_ON
 void
 lgtd_proto_power_on(struct lgtd_client *client,
-                    const struct lgtd_proto_target_list *targets)
+                    const struct lgtd_proto_target_list *targets,
+                    int transition)
 {
     (void)client;
     (void)targets;
+    (void)transition;
 }
 #endif
 
 #ifndef MOCKED_LGTD_PROTO_POWER_OFF
 void
 lgtd_proto_power_off(struct lgtd_client *client,
-                     const struct lgtd_proto_target_list *targets)
+                     const struct lgtd_proto_target_list *targets,
+                     int transition)
 {
     (void)client;
     (void)targets;
+    (void)transition;
+}
+#endif
+
+#ifndef MOCKED_LGTD_PROTO_POWER_TOGGLE
+void
+lgtd_proto_power_toggle(struct lgtd_client *client,
+                        const struct lgtd_proto_target_list *targets,
+                        int transition)
+{
+    (void)client;
+    (void)targets;
+    (void)transition;
 }
 #endif
 
@@ -110,16 +126,6 @@
 }
 #endif
 
-#ifndef MOCKED_LGTD_PROTO_POWER_TOGGLE
-void
-lgtd_proto_power_toggle(struct lgtd_client *client,
-                        const struct lgtd_proto_target_list *targets)
-{
-    (void)client;
-    (void)targets;
-}
-#endif
-
 #ifndef MOCKED_LGTD_PROTO_SET_LABEL
 void
 lgtd_proto_set_label(struct lgtd_client *client,
diff --git a/tests/core/proto/test_proto_get_light_state.c b/tests/core/proto/test_proto_get_light_state.c
--- a/tests/core/proto/test_proto_get_light_state.c
+++ b/tests/core/proto/test_proto_get_light_state.c
@@ -1,7 +1,9 @@
 #include "proto.c"
 
+#include "effects/mock_all.h"
 #include "mock_client_buf.h"
 #include "mock_daemon.h"
+#include "mock_effect.h"
 #include "mock_gateway.h"
 #include "mock_event2.h"
 #include "mock_log.h"
diff --git a/tests/core/proto/test_proto_get_light_state_empty_device_list.c b/tests/core/proto/test_proto_get_light_state_empty_device_list.c
--- a/tests/core/proto/test_proto_get_light_state_empty_device_list.c
+++ b/tests/core/proto/test_proto_get_light_state_empty_device_list.c
@@ -1,5 +1,6 @@
 #include "proto.c"
 
+#include "effects/mock_all.h"
 #include "mock_client_buf.h"
 #include "mock_daemon.h"
 #include "mock_event2.h"
diff --git a/tests/core/proto/test_proto_get_light_state_label_overflow.c b/tests/core/proto/test_proto_get_light_state_label_overflow.c
--- a/tests/core/proto/test_proto_get_light_state_label_overflow.c
+++ b/tests/core/proto/test_proto_get_light_state_label_overflow.c
@@ -1,5 +1,6 @@
 #include "proto.c"
 
+#include "effects/mock_all.h"
 #include "mock_client_buf.h"
 #include "mock_daemon.h"
 #include "mock_gateway.h"
diff --git a/tests/core/proto/test_proto_get_light_state_null_device_list.c b/tests/core/proto/test_proto_get_light_state_null_device_list.c
--- a/tests/core/proto/test_proto_get_light_state_null_device_list.c
+++ b/tests/core/proto/test_proto_get_light_state_null_device_list.c
@@ -1,5 +1,6 @@
 #include "proto.c"
 
+#include "effects/mock_all.h"
 #include "mock_client_buf.h"
 #include "mock_daemon.h"
 #include "mock_event2.h"
diff --git a/tests/core/proto/test_proto_get_light_state_unknown_tag_id.c b/tests/core/proto/test_proto_get_light_state_unknown_tag_id.c
--- a/tests/core/proto/test_proto_get_light_state_unknown_tag_id.c
+++ b/tests/core/proto/test_proto_get_light_state_unknown_tag_id.c
@@ -1,5 +1,6 @@
 #include "proto.c"
 
+#include "effects/mock_all.h"
 #include "mock_client_buf.h"
 #include "mock_daemon.h"
 #include "mock_gateway.h"
diff --git a/tests/core/proto/test_proto_power_off.c b/tests/core/proto/test_proto_power_off.c
--- a/tests/core/proto/test_proto_power_off.c
+++ b/tests/core/proto/test_proto_power_off.c
@@ -1,5 +1,6 @@
 #include "proto.c"
 
+#include "effects/mock_all.h"
 #include "mock_client_buf.h"
 #include "mock_daemon.h"
 #include "mock_event2.h"
@@ -60,7 +61,7 @@
     struct lgtd_client *client;
     client = lgtd_tests_insert_mock_client(FAKE_BUFFEREVENT);
 
-    lgtd_proto_power_off(client, targets);
+    lgtd_proto_power_off(client, targets, 0);
 
     return 0;
 }
diff --git a/tests/core/proto/test_proto_power_off_routing_error.c b/tests/core/proto/test_proto_power_off_routing_error.c
--- a/tests/core/proto/test_proto_power_off_routing_error.c
+++ b/tests/core/proto/test_proto_power_off_routing_error.c
@@ -1,5 +1,6 @@
 #include "proto.c"
 
+#include "effects/mock_all.h"
 #include "mock_client_buf.h"
 #include "mock_daemon.h"
 #include "mock_event2.h"
@@ -60,7 +61,7 @@
     struct lgtd_client *client;
     client = lgtd_tests_insert_mock_client(FAKE_BUFFEREVENT);
 
-    lgtd_proto_power_off(client, targets);
+    lgtd_proto_power_off(client, targets, 0);
 
     return 0;
 }
diff --git a/tests/core/proto/test_proto_power_on.c b/tests/core/proto/test_proto_power_on.c
--- a/tests/core/proto/test_proto_power_on.c
+++ b/tests/core/proto/test_proto_power_on.c
@@ -1,5 +1,6 @@
 #include "proto.c"
 
+#include "effects/mock_all.h"
 #include "mock_client_buf.h"
 #include "mock_daemon.h"
 #include "mock_event2.h"
@@ -60,7 +61,7 @@
     struct lgtd_client *client;
     client = lgtd_tests_insert_mock_client(FAKE_BUFFEREVENT);
 
-    lgtd_proto_power_on(client, targets);
+    lgtd_proto_power_on(client, targets, 0);
 
     return 0;
 }
diff --git a/tests/core/proto/test_proto_power_on_routing_error.c b/tests/core/proto/test_proto_power_on_routing_error.c
--- a/tests/core/proto/test_proto_power_on_routing_error.c
+++ b/tests/core/proto/test_proto_power_on_routing_error.c
@@ -1,5 +1,6 @@
 #include "proto.c"
 
+#include "effects/mock_all.h"
 #include "mock_client_buf.h"
 #include "mock_daemon.h"
 #include "mock_event2.h"
@@ -60,7 +61,7 @@
     struct lgtd_client *client;
     client = lgtd_tests_insert_mock_client(FAKE_BUFFEREVENT);
 
-    lgtd_proto_power_on(client, targets);
+    lgtd_proto_power_on(client, targets, 0);
 
     return 0;
 }
diff --git a/tests/core/proto/test_proto_power_toggle.c b/tests/core/proto/test_proto_power_toggle.c
--- a/tests/core/proto/test_proto_power_toggle.c
+++ b/tests/core/proto/test_proto_power_toggle.c
@@ -1,5 +1,6 @@
 #include "proto.c"
 
+#include "effects/mock_all.h"
 #include "mock_client_buf.h"
 #include "mock_daemon.h"
 #include "mock_gateway.h"
@@ -133,7 +134,7 @@
     client = lgtd_tests_insert_mock_client(FAKE_BUFFEREVENT);
     struct lgtd_proto_target_list *targets = (void *)0x2a;
 
-    lgtd_proto_power_toggle(client, targets);
+    lgtd_proto_power_toggle(client, targets, 0);
 
     const char expected[] = "true";
 
diff --git a/tests/core/proto/test_proto_power_toggle_targets_to_device_fails.c b/tests/core/proto/test_proto_power_toggle_targets_to_device_fails.c
--- a/tests/core/proto/test_proto_power_toggle_targets_to_device_fails.c
+++ b/tests/core/proto/test_proto_power_toggle_targets_to_device_fails.c
@@ -1,5 +1,6 @@
 #include "proto.c"
 
+#include "effects/mock_all.h"
 #include "mock_client_buf.h"
 #include "mock_daemon.h"
 #include "mock_gateway.h"
@@ -72,7 +73,7 @@
     client = lgtd_tests_insert_mock_client(FAKE_BUFFEREVENT);
     struct lgtd_proto_target_list *targets = (void *)0x2a;
 
-    lgtd_proto_power_toggle(client, targets);
+    lgtd_proto_power_toggle(client, targets, 0);
 
     if (client_send_error_call_count != 1) {
         errx(1, "lgtd_client_send_error called %d times (expected 1)",
diff --git a/tests/core/proto/test_proto_set_label.c b/tests/core/proto/test_proto_set_label.c
--- a/tests/core/proto/test_proto_set_label.c
+++ b/tests/core/proto/test_proto_set_label.c
@@ -1,5 +1,6 @@
 #include "proto.c"
 
+#include "effects/mock_all.h"
 #include "mock_client_buf.h"
 #include "mock_daemon.h"
 #include "mock_event2.h"
diff --git a/tests/core/proto/test_proto_set_label_too_long.c b/tests/core/proto/test_proto_set_label_too_long.c
--- a/tests/core/proto/test_proto_set_label_too_long.c
+++ b/tests/core/proto/test_proto_set_label_too_long.c
@@ -1,5 +1,6 @@
 #include "proto.c"
 
+#include "effects/mock_all.h"
 #include "mock_client_buf.h"
 #include "mock_daemon.h"
 #include "mock_event2.h"
diff --git a/tests/core/proto/test_proto_set_light_from_hsbk.c b/tests/core/proto/test_proto_set_light_from_hsbk.c
--- a/tests/core/proto/test_proto_set_light_from_hsbk.c
+++ b/tests/core/proto/test_proto_set_light_from_hsbk.c
@@ -2,6 +2,7 @@
 
 #include "proto.c"
 
+#include "effects/mock_all.h"
 #include "mock_client_buf.h"
 #include "mock_daemon.h"
 #include "mock_event2.h"
diff --git a/tests/core/proto/test_proto_set_light_from_hsbk_on_routing_error.c b/tests/core/proto/test_proto_set_light_from_hsbk_on_routing_error.c
--- a/tests/core/proto/test_proto_set_light_from_hsbk_on_routing_error.c
+++ b/tests/core/proto/test_proto_set_light_from_hsbk_on_routing_error.c
@@ -2,6 +2,7 @@
 
 #include "proto.c"
 
+#include "effects/mock_all.h"
 #include "mock_client_buf.h"
 #include "mock_daemon.h"
 #include "mock_event2.h"
diff --git a/tests/core/proto/test_proto_set_waveform.c b/tests/core/proto/test_proto_set_waveform.c
--- a/tests/core/proto/test_proto_set_waveform.c
+++ b/tests/core/proto/test_proto_set_waveform.c
@@ -2,6 +2,7 @@
 
 #include "proto.c"
 
+#include "effects/mock_all.h"
 #include "mock_client_buf.h"
 #include "mock_daemon.h"
 #include "mock_event2.h"
diff --git a/tests/core/proto/test_proto_set_waveform_on_routing_error.c b/tests/core/proto/test_proto_set_waveform_on_routing_error.c
--- a/tests/core/proto/test_proto_set_waveform_on_routing_error.c
+++ b/tests/core/proto/test_proto_set_waveform_on_routing_error.c
@@ -2,6 +2,7 @@
 
 #include "proto.c"
 
+#include "effects/mock_all.h"
 #include "mock_client_buf.h"
 #include "mock_daemon.h"
 #include "mock_event2.h"
diff --git a/tests/core/proto/test_proto_tag_create.c b/tests/core/proto/test_proto_tag_create.c
--- a/tests/core/proto/test_proto_tag_create.c
+++ b/tests/core/proto/test_proto_tag_create.c
@@ -1,5 +1,6 @@
 #include "proto.c"
 
+#include "effects/mock_all.h"
 #include "mock_client_buf.h"
 #include "mock_daemon.h"
 #define MOCKED_LIFX_GATEWAY_SEND_TO_SITE
diff --git a/tests/core/proto/test_proto_tag_create_lifx_gw_tag_ids_full.c b/tests/core/proto/test_proto_tag_create_lifx_gw_tag_ids_full.c
--- a/tests/core/proto/test_proto_tag_create_lifx_gw_tag_ids_full.c
+++ b/tests/core/proto/test_proto_tag_create_lifx_gw_tag_ids_full.c
@@ -1,5 +1,6 @@
 #include "proto.c"
 
+#include "effects/mock_all.h"
 #include "mock_client_buf.h"
 #include "mock_daemon.h"
 #define MOCKED_LIFX_GATEWAY_SEND_TO_SITE
diff --git a/tests/core/proto/test_proto_tag_update.c b/tests/core/proto/test_proto_tag_update.c
--- a/tests/core/proto/test_proto_tag_update.c
+++ b/tests/core/proto/test_proto_tag_update.c
@@ -1,5 +1,6 @@
 #include "proto.c"
 
+#include "effects/mock_all.h"
 #include "mock_client_buf.h"
 #include "mock_daemon.h"
 #define MOCKED_LIFX_GATEWAY_SEND_TO_SITE
diff --git a/tests/core/proto/test_proto_untag.c b/tests/core/proto/test_proto_untag.c
--- a/tests/core/proto/test_proto_untag.c
+++ b/tests/core/proto/test_proto_untag.c
@@ -1,5 +1,6 @@
 #include "proto.c"
 
+#include "effects/mock_all.h"
 #include "mock_client_buf.h"
 #include "mock_daemon.h"
 #include "mock_gateway.h"
diff --git a/tests/core/proto/test_proto_untag_tag_does_not_exist.c b/tests/core/proto/test_proto_untag_tag_does_not_exist.c
--- a/tests/core/proto/test_proto_untag_tag_does_not_exist.c
+++ b/tests/core/proto/test_proto_untag_tag_does_not_exist.c
@@ -1,5 +1,6 @@
 #include "proto.c"
 
+#include "effects/mock_all.h"
 #include "mock_client_buf.h"
 #include "mock_daemon.h"
 #include "mock_gateway.h"
diff --git a/tests/core/tests_utils.c b/tests/core/tests_utils.c
--- a/tests/core/tests_utils.c
+++ b/tests/core/tests_utils.c
@@ -74,7 +74,13 @@
         .as_scalar = LGTD_BIG_ENDIAN_SYSTEM ?
             htobe64(addr) << 16 : htobe64(addr) >> 16
     };
-    struct lgtd_lifx_bulb *bulb = lgtd_lifx_bulb_open(gw, bulb_addr.as_array);
+
+    struct lgtd_lifx_bulb *bulb = calloc(1, sizeof(*bulb));
+    bulb->gw = gw;
+    memcpy(bulb->addr, bulb_addr.as_array, sizeof(bulb->addr));
+    RB_INSERT(lgtd_lifx_bulb_map, &lgtd_lifx_bulbs_table, bulb);
+    LGTD_STATS_ADD_AND_UPDATE_PROCTITLE(bulbs, 1);
+    bulb->last_light_state_at = lgtd_time_monotonic_msecs();
 
     SLIST_INSERT_HEAD(&gw->bulbs, bulb, link_by_gw);
 
diff --git a/tests/effects/CMakeLists.txt b/tests/effects/CMakeLists.txt
new file mode 100644
--- /dev/null
+++ b/tests/effects/CMakeLists.txt
@@ -0,0 +1,10 @@
+INCLUDE_DIRECTORIES(
+    ${LIGHTSD_SOURCE_DIR}
+    ${CMAKE_CURRENT_SOURCE_DIR}
+    ${CMAKE_CURRENT_SOURCE_DIR}/../
+    ${LIGHTSD_BINARY_DIR}
+    ${CMAKE_CURRENT_BINARY_DIR}
+    ${CMAKE_CURRENT_BINARY_DIR}/../
+)
+
+ADD_ALL_SUBDIRECTORIES()
diff --git a/tests/effects/mock_all.h b/tests/effects/mock_all.h
new file mode 100644
--- /dev/null
+++ b/tests/effects/mock_all.h
@@ -0,0 +1,15 @@
+#pragma once
+
+#ifndef MOCKED_LGTD_EFFECT_POWER_TRANSITION
+const struct lgtd_effect *
+lgtd_effect_power_transition(const struct lgtd_proto_target_list *targets,
+                             enum lgtd_effect_power_transition_type state,
+                             int duration)
+{
+    (void)targets;
+    (void)state;
+    (void)duration;
+
+    return NULL;
+}
+#endif
diff --git a/tests/effects/power_transition/CMakeLists.txt b/tests/effects/power_transition/CMakeLists.txt
new file mode 100644
--- /dev/null
+++ b/tests/effects/power_transition/CMakeLists.txt
@@ -0,0 +1,21 @@
+INCLUDE_DIRECTORIES(
+    ${CMAKE_CURRENT_SOURCE_DIR}
+    ${CMAKE_CURRENT_BINARY_DIR}
+)
+
+ADD_CORE_LIBRARY(
+    test_effects_power_transition STATIC
+    ${LIGHTSD_SOURCE_DIR}/core/stats.c
+    ${LIGHTSD_SOURCE_DIR}/core/utils.c
+    ../../core/tests_utils.c
+    ../../core/tests_shims.c
+)
+
+FUNCTION(ADD_POWER_TRANSITION_TEST TEST_SOURCE)
+    ADD_TEST_FROM_C_SOURCES(${TEST_SOURCE} test_effects_power_transition)
+ENDFUNCTION()
+
+FILE(GLOB TESTS RELATIVE ${CMAKE_CURRENT_SOURCE_DIR} "test_*.c")
+FOREACH(TEST ${TESTS})
+    ADD_POWER_TRANSITION_TEST(${TEST})
+ENDFOREACH()
diff --git a/tests/effects/power_transition/test_power_transition_clear_target_list.c b/tests/effects/power_transition/test_power_transition_clear_target_list.c
new file mode 100644
--- /dev/null
+++ b/tests/effects/power_transition/test_power_transition_clear_target_list.c
@@ -0,0 +1,29 @@
+#include "effects/power_transition.c"
+
+#include "core/mock_effect.h"
+#include "core/mock_log.h"
+#include "core/mock_router.h"
+#include "lifx/mock_bulb.h"
+#include "lifx/mock_wire_proto.h"
+
+int
+main(void)
+{
+    struct lgtd_effect_power_transition_target *targets = calloc(
+        2, sizeof(*targets)
+    );
+    struct lgtd_effect_power_transition_target_list list;
+    memset(&list, 0, sizeof(list));
+    for (int i = 0; i != LGTD_ARRAY_SIZE(targets); i++) {
+        SLIST_INSERT_HEAD(&list, &targets[i], link);
+    }
+
+    for (int i = 0; i != 2; i++) {
+        lgtd_effect_power_transition_clear_target_list(&list);
+        if (!SLIST_EMPTY(&list)) {
+            lgtd_errx(1, "the list wasn't cleared");
+        }
+    }
+
+    return 0;
+}
diff --git a/tests/effects/power_transition/test_power_transition_off_apply_callback.c b/tests/effects/power_transition/test_power_transition_off_apply_callback.c
new file mode 100644
--- /dev/null
+++ b/tests/effects/power_transition/test_power_transition_off_apply_callback.c
@@ -0,0 +1,201 @@
+#include "effects/power_transition.c"
+
+#include "core/mock_daemon.h"
+#include "core/mock_effect.h"
+#include "core/mock_log.h"
+#define MOCKED_LGTD_ROUTER_SEND_TO_DEVICE
+#include "core/mock_router.h"
+#define MOCKED_LGTD_LIFX_BULB_GET
+#include "lifx/mock_bulb.h"
+#include "lifx/mock_gateway.h"
+#include "lifx/mock_tagging.h"
+#include "lifx/mock_wire_proto.h"
+
+#include "core/tests_utils.h"
+
+enum { TEST_TRANSITION_DURATION = 120 };
+
+struct lgtd_lifx_bulb *mock_bulb_1 = NULL;
+struct lgtd_lifx_bulb *mock_bulb_2 = NULL;
+enum { MOCK_BULB_2_INITIAL_BRIGHTNESS = 0xafaf };
+struct lgtd_effect *mock_effect = NULL;
+
+static int lifx_bulb_get_call_count = 0;
+
+struct lgtd_lifx_bulb *lgtd_lifx_bulb_get(const uint8_t *addr)
+{
+    if (lifx_bulb_get_call_count++ >= 2) {
+        return NULL;
+    }
+
+    if (memcmp(addr, mock_bulb_2->addr, sizeof(mock_bulb_2->addr))) {
+        char bufs[2][LGTD_LIFX_ADDR_STRLEN];
+        lgtd_errx(
+            1, "bulb_get: expected bulb %s but got %s instead",
+            LGTD_IEEE8023MACTOA(mock_bulb_2->addr, bufs[0]),
+            LGTD_IEEE8023MACTOA(addr, bufs[1])
+        );
+    }
+
+    return mock_bulb_2;
+}
+
+static int router_send_to_device_call_count = 0;
+
+void
+lgtd_router_send_to_device(struct lgtd_lifx_bulb *bulb,
+                           enum lgtd_lifx_packet_type pkt_type,
+                           void *pkt)
+{
+    if (bulb != mock_bulb_2) {
+        char bufs[2][LGTD_LIFX_ADDR_STRLEN];
+        lgtd_errx(
+            1, "router_send_to_device: expected bulb %s but got %s instead",
+            LGTD_IEEE8023MACTOA(mock_bulb_2->addr, bufs[0]),
+            LGTD_IEEE8023MACTOA(bulb->addr, bufs[1])
+        );
+    }
+
+    struct lgtd_lifx_packet_light_color *pkt_brightness;
+    struct lgtd_lifx_packet_power *pkt_power;
+    switch (router_send_to_device_call_count++) {
+    case 0: // set brightness to zero
+        pkt_brightness = pkt;
+        if (pkt_type != LGTD_LIFX_SET_LIGHT_COLOR) {
+            lgtd_errx(
+                1, "brightness transition: pkt_type = %d (expected %d)",
+                pkt_type, LGTD_LIFX_SET_LIGHT_COLOR
+            );
+        }
+        if (pkt_brightness->hue != mock_bulb_2->state.hue) {
+            lgtd_errx(
+                1, "brightness transition: hue = %hu (expected %hu)",
+                pkt_brightness->hue, mock_bulb_2->state.hue
+            );
+        }
+        if (pkt_brightness->saturation != mock_bulb_2->state.saturation) {
+            lgtd_errx(
+                1, "brightness transition: saturation = %hu (expected %hu)",
+                pkt_brightness->saturation, mock_bulb_2->state.saturation
+            );
+        }
+        if (pkt_brightness->brightness != 0) {
+            lgtd_errx(
+                1, "brightness transition: brightness = %hu (expected 0)",
+                pkt_brightness->brightness
+            );
+        }
+        if (pkt_brightness->kelvin != bulb->state.kelvin) {
+            lgtd_errx(
+                1, "brightness transition: kelvin = %hu (expected 0)",
+                pkt_brightness->kelvin
+            );
+        }
+        if (pkt_brightness->transition != TEST_TRANSITION_DURATION) {
+            lgtd_errx(
+                1, "brightness transition: transition = %u (expected %u)",
+                pkt_brightness->transition, TEST_TRANSITION_DURATION
+            );
+        }
+        break;
+    case 1: // set power off
+        pkt_power = pkt;
+        if (pkt_type != LGTD_LIFX_SET_POWER_STATE) {
+            lgtd_errx(
+                1, "power off: pkt_type = %d (expected %d)",
+                pkt_type, LGTD_LIFX_SET_POWER_STATE
+            );
+        }
+        if (pkt_power->power != LGTD_LIFX_POWER_OFF) {
+            lgtd_errx(
+                1, "powering off: power = %hu (expected %u)",
+                pkt_power->power, LGTD_LIFX_POWER_OFF
+            );
+        }
+        break;
+    default:
+        break;
+    }
+}
+
+int
+main(void)
+{
+    struct lgtd_lifx_gateway *gw_1 = lgtd_tests_insert_mock_gateway(1);
+    mock_bulb_1 = lgtd_tests_insert_mock_bulb(gw_1, 1);
+    mock_bulb_2 = lgtd_tests_insert_mock_bulb(gw_1, 2);
+    mock_bulb_2->state.brightness = MOCK_BULB_2_INITIAL_BRIGHTNESS;
+    mock_bulb_2->state.hue = 0xbae;
+    mock_bulb_2->state.saturation = 0x4444;
+    mock_bulb_2->state.kelvin = 3500;
+
+    struct lgtd_effect_power_transition_ctx ctx = {
+        .duration = TEST_TRANSITION_DURATION
+    };
+    struct lgtd_effect_power_transition_target transition_target = {
+        .initial_brightness = mock_bulb_2->state.brightness
+    };
+    memcpy(
+        &transition_target.device_id,
+        mock_bulb_2->addr,
+        sizeof(mock_bulb_2->addr)
+    );
+    SLIST_INSERT_HEAD(&ctx.targets, &transition_target, link);
+
+    mock_effect = calloc(1, sizeof(*mock_effect));
+    union lgtd_effect_ctx effect_ctx = { .as_ptr = &ctx };
+    mock_effect->ctx = effect_ctx;
+
+    // 1st application: should set brightness to zero on bulb_2:
+    // 2nd application: should turn the power off on bulb_2:
+    for (int i = 0; i++ != 2; mock_effect->apply_cnt++) {
+        lgtd_effect_power_transition_off_apply_callback(mock_effect);
+        if (lifx_bulb_get_call_count != i) {
+            lgtd_errx(
+                1, "lifx_bulb_get_call_count = %d (expected %d)",
+                lifx_bulb_get_call_count, i
+            );
+        }
+        if (router_send_to_device_call_count != i) {
+            lgtd_errx(
+                1, "router_send_to_device_call_count = %d (expected %d)",
+                router_send_to_device_call_count, i
+            );
+        }
+    }
+
+    // shouldn't do anything:
+    lgtd_effect_power_transition_off_apply_callback(mock_effect);
+    if (lifx_bulb_get_call_count != 2) {
+        lgtd_errx(
+            1, "lifx_bulb_get_call_count = %d (expected 2)",
+            lifx_bulb_get_call_count
+        );
+    }
+    if (router_send_to_device_call_count != 2) {
+        lgtd_errx(
+            1, "router_send_to_device_call_count = %d (expected 2)",
+            router_send_to_device_call_count
+        );
+    }
+
+    // repeat the first test case with lgtd_lifx_bulb_get returning NULL:
+    mock_effect->apply_cnt = 0;
+    for (int i = 0; i++ != 2; mock_effect->apply_cnt++) {
+        lgtd_effect_power_transition_off_apply_callback(mock_effect);
+        if (lifx_bulb_get_call_count != 2 + i) {
+            lgtd_errx(
+                1, "lifx_bulb_get_call_count = %d (expected %d)",
+                lifx_bulb_get_call_count, 2 + i
+            );
+        }
+        if (router_send_to_device_call_count != 2) {
+            lgtd_errx(
+                1, "router_send_to_device_call_count = %d (expected 2)",
+                router_send_to_device_call_count
+            );
+        }
+    }
+
+    return 0;
+}
diff --git a/tests/effects/power_transition/test_power_transition_off_duration_callback.c b/tests/effects/power_transition/test_power_transition_off_duration_callback.c
new file mode 100644
--- /dev/null
+++ b/tests/effects/power_transition/test_power_transition_off_duration_callback.c
@@ -0,0 +1,160 @@
+#include "effects/power_transition.c"
+
+#include "core/mock_daemon.h"
+#include "core/mock_effect.h"
+#include "core/mock_log.h"
+#define MOCKED_LGTD_ROUTER_SEND_TO_DEVICE
+#include "core/mock_router.h"
+#define MOCKED_LGTD_LIFX_BULB_GET
+#include "lifx/mock_bulb.h"
+#include "lifx/mock_gateway.h"
+#include "lifx/mock_tagging.h"
+#include "lifx/mock_wire_proto.h"
+
+#include "core/tests_utils.h"
+
+struct lgtd_lifx_bulb *mock_bulb_1 = NULL;
+struct lgtd_lifx_bulb *mock_bulb_2 = NULL;
+enum { MOCK_BULB_2_INITIAL_BRIGHTNESS = 0xafaf };
+struct lgtd_effect *mock_effect = NULL;
+
+static int lifx_bulb_get_call_count = 0;
+
+struct lgtd_lifx_bulb *lgtd_lifx_bulb_get(const uint8_t *addr)
+{
+    if (lifx_bulb_get_call_count++) {
+        return NULL;
+    }
+
+    if (memcmp(addr, mock_bulb_2->addr, sizeof(mock_bulb_2->addr))) {
+        char bufs[2][LGTD_LIFX_ADDR_STRLEN];
+        lgtd_errx(
+            1, "bulb_get: expected bulb %s but got %s instead",
+            LGTD_IEEE8023MACTOA(mock_bulb_2->addr, bufs[0]),
+            LGTD_IEEE8023MACTOA(addr, bufs[1])
+        );
+    }
+
+    return mock_bulb_2;
+}
+
+static int router_send_to_device_call_count = 0;
+
+void
+lgtd_router_send_to_device(struct lgtd_lifx_bulb *bulb,
+                           enum lgtd_lifx_packet_type pkt_type,
+                           void *pkt)
+{
+    router_send_to_device_call_count++;
+
+    if (bulb != mock_bulb_2) {
+        char bufs[2][LGTD_LIFX_ADDR_STRLEN];
+        lgtd_errx(
+            1, "router_send_to_device: expected bulb %s but got %s instead",
+            LGTD_IEEE8023MACTOA(mock_bulb_2->addr, bufs[0]),
+            LGTD_IEEE8023MACTOA(bulb->addr, bufs[1])
+        );
+    }
+
+    struct lgtd_lifx_packet_light_color *pkt_brightness = pkt;
+    if (pkt_type != LGTD_LIFX_SET_LIGHT_COLOR) {
+        lgtd_errx(
+            1, "router_send_to_device: pkt_type %u (expected %u)",
+            pkt_type, LGTD_LIFX_SET_LIGHT_COLOR
+        );
+    }
+    if (pkt_brightness->hue != mock_bulb_2->state.hue) {
+        lgtd_errx(
+            1, "brightness restore: hue = %hu (expected %hu)",
+            pkt_brightness->hue, mock_bulb_2->state.hue
+        );
+    }
+    if (pkt_brightness->saturation != mock_bulb_2->state.saturation) {
+        lgtd_errx(
+            1, "brightness restore: saturation = %hu (expected %hu)",
+            pkt_brightness->saturation, mock_bulb_2->state.saturation
+        );
+    }
+    if (pkt_brightness->brightness != MOCK_BULB_2_INITIAL_BRIGHTNESS) {
+        lgtd_errx(
+            1, "brightness restore: brightness = %hu (expected %u)",
+            pkt_brightness->brightness, MOCK_BULB_2_INITIAL_BRIGHTNESS
+        );
+    }
+    if (pkt_brightness->kelvin != bulb->state.kelvin) {
+        lgtd_errx(
+            1, "brightness restore: kelvin = %hu (expected 0)",
+            pkt_brightness->kelvin
+        );
+    }
+    if (pkt_brightness->transition != 0) {
+        lgtd_errx(
+            1, "brightness restore: transition = %u (expected 0)",
+            pkt_brightness->transition
+        );
+    }
+}
+
+static struct lgtd_effect_power_transition_ctx *
+allocate_ctx(void)
+{
+    struct lgtd_effect_power_transition_ctx *ctx = calloc(1, sizeof(*ctx));
+    struct lgtd_effect_power_transition_target *transition_target = calloc(
+        1, sizeof(*ctx)
+    );
+    transition_target->initial_brightness = MOCK_BULB_2_INITIAL_BRIGHTNESS;
+    memcpy(
+        &transition_target->device_id,
+        mock_bulb_2->addr,
+        sizeof(mock_bulb_2->addr)
+    );
+    SLIST_INSERT_HEAD(&ctx->targets, transition_target, link);
+
+    return ctx;
+}
+
+int
+main(void)
+{
+    struct lgtd_lifx_gateway *gw_1 = lgtd_tests_insert_mock_gateway(1);
+    mock_bulb_1 = lgtd_tests_insert_mock_bulb(gw_1, 1);
+    mock_bulb_2 = lgtd_tests_insert_mock_bulb(gw_1, 2);
+    mock_bulb_2->state.brightness = 0;
+    mock_bulb_2->state.hue = 0xbae;
+    mock_bulb_2->state.saturation = 0x4444;
+    mock_bulb_2->state.kelvin = 3500;
+
+    mock_effect = calloc(1, sizeof(*mock_effect));
+
+    mock_effect->ctx.as_ptr = allocate_ctx();
+    lgtd_effect_power_transition_off_duration_callback(mock_effect);
+    if (lifx_bulb_get_call_count != 1) {
+        lgtd_errx(
+            1, "bulb_get_call_count = %d (expected 1)", lifx_bulb_get_call_count
+        );
+    }
+    if (router_send_to_device_call_count != 1) {
+        lgtd_errx(
+            1, "bulb_get_call_count = %d (expected 1)",
+            router_send_to_device_call_count
+        );
+    }
+
+    // if the bulb was turned before the end of the transition make sure that's
+    // handled correctly:
+    mock_effect->ctx.as_ptr = allocate_ctx();
+    lgtd_effect_power_transition_off_duration_callback(mock_effect);
+    if (lifx_bulb_get_call_count != 2) {
+        lgtd_errx(
+            1, "bulb_get_call_count = %d (expected 2)", lifx_bulb_get_call_count
+        );
+    }
+    if (router_send_to_device_call_count != 1) {
+        lgtd_errx(
+            1, "bulb_get_call_count = %d (expected 1)",
+            router_send_to_device_call_count
+        );
+    }
+
+    return 0;
+}
diff --git a/tests/effects/power_transition/test_power_transition_off_possibly_get_bulb.c b/tests/effects/power_transition/test_power_transition_off_possibly_get_bulb.c
new file mode 100644
--- /dev/null
+++ b/tests/effects/power_transition/test_power_transition_off_possibly_get_bulb.c
@@ -0,0 +1,46 @@
+#include "effects/power_transition.c"
+
+#include "core/mock_effect.h"
+#include "core/mock_log.h"
+#include "core/mock_router.h"
+#define MOCKED_LGTD_LIFX_BULB_GET
+#include "lifx/mock_bulb.h"
+#include "lifx/mock_wire_proto.h"
+
+struct lgtd_lifx_bulb TEST_BULB = { .addr = { 0, 1, 2, 3, 4, 5 } };
+
+static int bulb_get_call_count = 0;
+
+struct lgtd_lifx_bulb *
+lgtd_lifx_bulb_get(const uint8_t *addr)
+{
+    if (addr != TEST_BULB.addr) {
+        lgtd_errx(
+            1, "unexpected addr %p (expected %p)", addr, TEST_BULB.addr
+        );
+    }
+
+    if (!bulb_get_call_count++) {
+        return &TEST_BULB;
+    }
+
+    return NULL;
+}
+
+int
+main(void)
+{
+    struct lgtd_lifx_bulb *bulb;
+
+    bulb = lgtd_effect_power_transition_off_possibly_get_bulb(TEST_BULB.addr);
+    if (!bulb) {
+        lgtd_errx(1, "expected a bulb but got NULL instead");
+    }
+
+    bulb = lgtd_effect_power_transition_off_possibly_get_bulb(TEST_BULB.addr);
+    if (bulb) {
+        lgtd_errx(1, "expected NULL but got %p instead", bulb);
+    }
+
+    return 0;
+}
diff --git a/tests/effects/power_transition/test_power_transition_on_apply_callback.c b/tests/effects/power_transition/test_power_transition_on_apply_callback.c
new file mode 100644
--- /dev/null
+++ b/tests/effects/power_transition/test_power_transition_on_apply_callback.c
@@ -0,0 +1,5 @@
+int
+main(void)
+{
+    return 0;
+}
diff --git a/tests/lifx/mock_bulb.h b/tests/lifx/mock_bulb.h
--- a/tests/lifx/mock_bulb.h
+++ b/tests/lifx/mock_bulb.h
@@ -1,5 +1,8 @@
 #pragma once
 
+struct lgtd_lifx_bulb_map lgtd_lifx_bulbs_table =
+    RB_INITIALIZER(&lgtd_lifx_bulbs_table);
+
 #ifndef MOCKED_LGTD_LIFX_BULB_GET
 struct lgtd_lifx_bulb *
 lgtd_lifx_bulb_get(const uint8_t *addr)