RGB Sequencer in DSL rules

Hey all,

with the help of your friendly neighborhood AI chatbot, here’s a RGB sequencer for some RGBW Lights.

In my case, I have three Dimmer channels for Red, Green, Blue and White that accept values between 0 and 100. There is a selector called “EffektTyp” that selects via an integer (see the case: statement) which sequence you want to run and an “EffektStart” switch that starts the sequencer when switched to ON and shuts everything off when switched to OFF.

The “magic” is in the timer that reschedules itself with a frequency of 1 Hz and calculates the next RGBW value combination based on the time that has elapsed since the sequencer start.

I called the effects: 1-rainbow, 2-pulse, 3-candle, 4-sunset. I am sure you can get creative with more effects.

// ============================================================================
// RGBW Effekt-Sequencer (openHAB Rules DSL) - robust, no global closures
//
// Items (Dimmer 0..100):
//   gEG_KU_RGB_R, gEG_KU_RGB_G, gEG_KU_RGB_B, gEG_KU_RGB_W
// Selector:
//   Number gEG_KU_EffektTyp
// Start/Stop:
//   Switch gEG_KU_EffektStart
//
// EffektTyp 1: Full rainbow, 5 minutes per loop, W=0
// EffektTyp 2: "Breathing" tri-color fade (deep blue -> purple -> magenta -> deep blue), W=0
// EffektTyp 3: Candle flicker (warm hue + slight W + slight green reduction)
// EffektTyp 4: Sunset gradient palette loop (slow curated fades), slight W allowed
//
// Runs at 1 Hz while EffektStart = ON
// OFF stops timer AND sets all channels to 0
// ============================================================================

var Timer gEG_KU_effektTimer = null
var long  gEG_KU_effektStartMs = 0

// Random generator for flicker (EffektTyp 3)
var java.util.Random gEG_KU_rnd = new java.util.Random()

// ----------------------------------------------------------------------------
// Start/Stop rule
// ----------------------------------------------------------------------------
rule "Kueche RGBW Effekt Start/Stop"
when
    Item gEG_KU_EffektStart changed
then
    if (gEG_KU_EffektStart.state == ON) {

        // Baseline so effects start at their "beginning"
        gEG_KU_effektStartMs = now.toInstant.toEpochMilli

        // Cancel an existing timer if any
        if (gEG_KU_effektTimer !== null) {
            gEG_KU_effektTimer.cancel()
            gEG_KU_effektTimer = null
        }

        // Create ONE timer; inside it we reschedule via gEG_KU_effektTimer.reschedule(...)
        gEG_KU_effektTimer = createTimer(now, [ |
            // If switched off while waiting/running: stop + blackout
            if (gEG_KU_EffektStart.state != ON) {
                gEG_KU_RGB_R.sendCommand(0)
                gEG_KU_RGB_G.sendCommand(0)
                gEG_KU_RGB_B.sendCommand(0)
                gEG_KU_RGB_W.sendCommand(0)

                if (gEG_KU_effektTimer !== null) {
                    gEG_KU_effektTimer.cancel()
                    gEG_KU_effektTimer = null
                }
                return
            }

            // Read EffektTyp (default 0 if not a number)
            var int typ = 0
            if (gEG_KU_EffektTyp.state instanceof Number) {
                typ = (gEG_KU_EffektTyp.state as Number).intValue
            }

            // Common time base
            val long nowMs = now.toInstant.toEpochMilli

            switch (typ) {

                // ============================================================
                // Effekt 1: 5-minute full hue circle, sat=1, val=1, white OFF
                // ============================================================
                case 1: {
                    val long periodMs = 300000 // 5 minutes
                    val long elapsed = nowMs - gEG_KU_effektStartMs
                    val long t = if (elapsed < 0) 0 else (elapsed % periodMs)

                    var double hue = (t as double) / (periodMs as double) * 360.0
                    while (hue < 0.0)    hue = hue + 360.0
                    while (hue >= 360.0) hue = hue - 360.0

                    // HSV->RGB with s=1, v=1
                    val double c = 1.0
                    val double hPrime = hue / 60.0
                    val double x = c * (1.0 - Math::abs((hPrime % 2.0) - 1.0))
                    val double m = 0.0

                    var double r1 = 0.0
                    var double g1 = 0.0
                    var double b1 = 0.0

                    if      (hPrime < 1.0) { r1 = c; g1 = x; b1 = 0.0 }
                    else if (hPrime < 2.0) { r1 = x; g1 = c; b1 = 0.0 }
                    else if (hPrime < 3.0) { r1 = 0.0; g1 = c; b1 = x }
                    else if (hPrime < 4.0) { r1 = 0.0; g1 = x; b1 = c }
                    else if (hPrime < 5.0) { r1 = x; g1 = 0.0; b1 = c }
                    else                   { r1 = c; g1 = 0.0; b1 = x }

                    var int r = Math::round((r1 + m) * 100.0) as int
                    var int g = Math::round((g1 + m) * 100.0) as int
                    var int b = Math::round((b1 + m) * 100.0) as int

                    if (r < 0) r = 0 else if (r > 100) r = 100
                    if (g < 0) g = 0 else if (g > 100) g = 100
                    if (b < 0) b = 0 else if (b > 100) b = 100

                    gEG_KU_RGB_R.sendCommand(r)
                    gEG_KU_RGB_G.sendCommand(g)
                    gEG_KU_RGB_B.sendCommand(b)
                    gEG_KU_RGB_W.sendCommand(0)
                }

                // ============================================================
                // Effekt 2: "Breathing" tri-color fade (deep blue -> purple -> magenta -> back)
                // - total cycle: 24s (8s per segment)
                // - smooth cosine fade, white OFF
                // Colors (0..100):
                //   A deep blue:  (0, 0, 100)
                //   B purple:     (50, 0, 100)
                //   C magenta:    (100, 0, 60)
                // ============================================================
                case 2: {
                    val long periodMs = 24000
                    val long segMs = 8000
                    val long elapsed = nowMs - gEG_KU_effektStartMs
                    val long t = if (elapsed < 0) 0 else (elapsed % periodMs)

                    val int seg = (t / segMs) as int // 0..2
                    val long within = t % segMs

                    // mix 0..1 using cosine easing
                    val double phase = (within as double) / (segMs as double) * Math::PI
                    val double mix = (1.0 - Math::cos(phase)) / 2.0

                    // segment endpoints
                    var int rA = 0
                    var int gA = 0
                    var int bA = 0
                    var int rB = 0
                    var int gB = 0
                    var int bB = 0

                    if (seg == 0) {
                        // A -> B
                        rA = 0;   gA = 0; bA = 100
                        rB = 50;  gB = 0; bB = 100
                    } else if (seg == 1) {
                        // B -> C
                        rA = 50;  gA = 0; bA = 100
                        rB = 100; gB = 0; bB = 60
                    } else {
                        // C -> A
                        rA = 100; gA = 0; bA = 60
                        rB = 0;   gB = 0; bB = 100
                    }

                    var int r = Math::round((rA * (1.0 - mix) + rB * mix)) as int
                    var int g = Math::round((gA * (1.0 - mix) + gB * mix)) as int
                    var int b = Math::round((bA * (1.0 - mix) + bB * mix)) as int

                    if (r < 0) r = 0 else if (r > 100) r = 100
                    if (g < 0) g = 0 else if (g > 100) g = 100
                    if (b < 0) b = 0 else if (b > 100) b = 100

                    gEG_KU_RGB_R.sendCommand(r)
                    gEG_KU_RGB_G.sendCommand(g)
                    gEG_KU_RGB_B.sendCommand(b)
                    gEG_KU_RGB_W.sendCommand(0)
                }

                // ============================================================
                // Effekt 3: Candle flicker (warm hue + slight W + slight green reduction)
                // - hue random in ~25..40° (warm orange)
                // - brightness random 60..100%
                // - saturation high (implicitly by using HSV conversion)
                // - slight white: 5..15%
                // - "green reduction": subtract a small amount from G to keep it warm
                // ============================================================
                case 3: {
                    // Random hue + brightness
                    val double hue = 25.0 + (gEG_KU_rnd.nextDouble() * 15.0)      // 25..40
                    val double v   = 0.60 + (gEG_KU_rnd.nextDouble() * 0.40)      // 0.60..1.00
                    val double s   = 1.0

                    // HSV->RGB
                    val double c = v * s
                    val double hPrime = hue / 60.0
                    val double x = c * (1.0 - Math::abs((hPrime % 2.0) - 1.0))
                    val double m = v - c

                    var double r1 = 0.0
                    var double g1 = 0.0
                    var double b1 = 0.0

                    if      (hPrime < 1.0) { r1 = c; g1 = x; b1 = 0.0 }
                    else if (hPrime < 2.0) { r1 = x; g1 = c; b1 = 0.0 }
                    else if (hPrime < 3.0) { r1 = 0.0; g1 = c; b1 = x }
                    else if (hPrime < 4.0) { r1 = 0.0; g1 = x; b1 = c }
                    else if (hPrime < 5.0) { r1 = x; g1 = 0.0; b1 = c }
                    else                   { r1 = c; g1 = 0.0; b1 = x }

                    var int r = Math::round((r1 + m) * 100.0) as int
                    var int g = Math::round((g1 + m) * 100.0) as int
                    var int b = Math::round((b1 + m) * 100.0) as int

                    if (r < 0) r = 0 else if (r > 100) r = 100
                    if (g < 0) g = 0 else if (g > 100) g = 100
                    if (b < 0) b = 0 else if (b > 100) b = 100

                    // small white component 5..15
                    var int w = 5 + (gEG_KU_rnd.nextInt(11)) // 5..15

                    // "green reduction" to keep it warm (0..8), but never below 0
                    val int gDrop = gEG_KU_rnd.nextInt(9) // 0..8
                    g = g - gDrop
                    if (g < 0) g = 0

                    gEG_KU_RGB_R.sendCommand(r)
                    gEG_KU_RGB_G.sendCommand(g)
                    gEG_KU_RGB_B.sendCommand(b)
                    gEG_KU_RGB_W.sendCommand(w)
                }

                // ============================================================
                // Effekt 4: Sunset gradient palette loop (slow curated fades)
                // - palette: amber -> deep orange -> hot pink -> violet -> deep blue -> back to amber
                // - 60s per segment, total loop 5 minutes (5 segments)
                // - gentle white: fixed 6% (optional "glow")
                // ============================================================
                case 4: {
                    val long segMs = 60000
                    val int  n = 5
                    val long periodMs = segMs * n

                    val long elapsed = nowMs - gEG_KU_effektStartMs
                    val long t = if (elapsed < 0) 0 else (elapsed % periodMs)

                    val int seg = (t / segMs) as int // 0..4
                    val long within = t % segMs
                    val double mix = (within as double) / (segMs as double) // linear fade 0..1

                    // Palette in 0..100 scale (hand-tuned)
                    // 0 amber     (100, 55, 10)
                    // 1 orange    (100, 30,  0)
                    // 2 pink      (100,  0, 45)
                    // 3 violet    ( 45,  0,100)
                    // 4 deep blue (  0,  0,100)

                    var int rA = 0
                    var int gA = 0
                    var int bA = 0
                    var int rB = 0
                    var int gB = 0
                    var int bB = 0

                    if (seg == 0) {
                        rA=100; gA=55; bA=10
                        rB=100; gB=30; bB=0
                    } else if (seg == 1) {
                        rA=100; gA=30; bA=0
                        rB=100; gB=0;  bB=45
                    } else if (seg == 2) {
                        rA=100; gA=0;  bA=45
                        rB=45;  gB=0;  bB=100
                    } else if (seg == 3) {
                        rA=45;  gA=0;  bA=100
                        rB=0;   gB=0;  bB=100
                    } else {
                        // seg == 4 : deep blue -> amber (wrap)
                        rA=0;   gA=0;  bA=100
                        rB=100; gB=55; bB=10
                    }

                    var int r = Math::round((rA * (1.0 - mix) + rB * mix)) as int
                    var int g = Math::round((gA * (1.0 - mix) + gB * mix)) as int
                    var int b = Math::round((bA * (1.0 - mix) + bB * mix)) as int

                    if (r < 0) r = 0 else if (r > 100) r = 100
                    if (g < 0) g = 0 else if (g > 100) g = 100
                    if (b < 0) b = 0 else if (b > 100) b = 100

                    val int w = 6 // small constant glow

                    gEG_KU_RGB_R.sendCommand(r)
                    gEG_KU_RGB_G.sendCommand(g)
                    gEG_KU_RGB_B.sendCommand(b)
                    gEG_KU_RGB_W.sendCommand(w)
                }

                // Default: off
                default: {
                    gEG_KU_RGB_R.sendCommand(0)
                    gEG_KU_RGB_G.sendCommand(0)
                    gEG_KU_RGB_B.sendCommand(0)
                    gEG_KU_RGB_W.sendCommand(0)
                }
            }

            // Re-schedule (NO new timer, no closure reuse problems)
            if (gEG_KU_effektTimer !== null) {
                gEG_KU_effektTimer.reschedule(now.plusSeconds(1))
            }
        ])

    } else {
        // OFF: cancel timer + all channels to 0
        if (gEG_KU_effektTimer !== null) {
            gEG_KU_effektTimer.cancel()
            gEG_KU_effektTimer = null
        }
        gEG_KU_RGB_R.sendCommand(0)
        gEG_KU_RGB_G.sendCommand(0)
        gEG_KU_RGB_B.sendCommand(0)
        gEG_KU_RGB_W.sendCommand(0)
    }
end

// ----------------------------------------------------------------------------
// Optional: when EffektTyp changes while running, restart cycle baseline
// ----------------------------------------------------------------------------
rule "Kueche RGBW EffektTyp changed while running"
when
    Item gEG_KU_EffektTyp changed
then
    if (gEG_KU_EffektStart.state == ON) {
        gEG_KU_effektStartMs = now.toInstant.toEpochMilli
    }
end

Enjoy!

3 Likes