Am I doing lights the right way? (Showcase + Question)

Now, first, I’m well aware that there’s often no strictly “right” or “wrong” way to do something, but I’ve solved what I think is a rather common/simple problem for home automation in a way that might be more complex than it needs to be. Particularly with plans to upgrade to OH3 in the future and then moving away from HabPanel, I’m wondering if there is a more simple way to accomplish what I accomplished.

Primarily, my goal was to control lights using OpenHab, with these requirements:

  • Multiple outputs: I have multiple different light setups in my home. There are Hue bulbs that need to be controlled, Shelly-relay and Shelly-dimmer devices, DIY-ESP controlled lamps, and even some HomeMatic devices that all control lights, and some even in overlapping groups. My system needed to be able to control these all.
  • Multiple inputs: Just like I have many types of lights, I have many types of inputs. These include Hue-remotes, Shelly switch inputs and buttons, HomeMatic devices, HabPanel widgets, and of course, rule automation. My system needed to be able to handle these all.
  • Efficient programming: With 20+ rooms/zones containing multiple lights each, I did not want to write individual rules for each room’s various forms of lighting. Whatever solution needed to keep the location-specific rules and programming to a minimum to allow for reasonable configuration times and later modificaction.
  • Custom profiles: I wanted lights to be able to follow profiles, controlled by the time of day. Specifically, I have many lights which allow temperature changing of their white value, and wanted warm/cold light along with brightness for ambient lights to follow circadian/daylight patterns. Eg, when you turn on the bathroom light at 2am, it defaults to 10% brightness so you don’t sear your eyes, but at noon it turns on to a default 100%.

What I eventually came up with involves a lot of virtual items carrying JSON packages. Basically, for each light-unit (ambient lighting, accent lighting, etc) in each room, there is a small host of virtual items. These virtual items are then linked to the location-specific bulbs, relays, and Hue groups in the location-specific rules and the various inputs (like HabPanel or switch inputs) then adjust these virtual items.

Here, for example, is an ambient light’s virtual item:

//Ambient Lights
    Switch RoomLight_Kitchen_Ambient1_Toggle    "Main Lights"           (RoomLight_Watchdog, RoomLight_Virtual)
    String RoomLight_Kitchen_Ambient1_Adaptive1 "JSON Store"            (RoomLight_Virtual)
    String RoomLight_Kitchen_Ambient1_Adaptive2 "JSON Store"            (RoomLight_Virtual)
    Dimmer RoomLight_Kitchen_Ambient1_Percent1  "Brightness percent"    (RoomLight_Watchdog, RoomLight_Virtual)
    Dimmer RoomLight_Kitchen_Ambient1_Target1   "Adaptive target"       (RoomLight_Virtual)
    Dimmer RoomLight_Kitchen_Ambient1_Percent2  "Temperature percent"   (RoomLight_Watchdog,RoomLight_Virtual)
    Dimmer RoomLight_Kitchen_Ambient1_Target2   "Adaptive target"       (RoomLight_Virtual)

The “Toggle” item is used to represent the light’s overall state and is the item that HabPanel widgets and physical buttons write to. The two “Adaptive” String items store JSON information that determines which profile they’re following and if the “Adaptive mode” is engaged (If it’s not, the light just goes to 100% when turned on). Adaptive1 is used for brightness, and Adaptive2 is used for temperature. These two items periodically (every three minutes) are used to fetch the appropriate current values according to their profiles from the store, and write them into the Target1 and Target2 items, which then determine the Percent1 and Percent2 values, which then directly reflect in the light’s physical brightness.

This all is controlled by this rule:

rule "Light Control V2 - Main"
when
    Member of RoomLight_Watchdog received command
then
    //Extract the root of the virtual light we're working with 
    var root_name = "" + triggeringItem.name.split('_').get(0) + "_" + triggeringItem.name.split('_').get(1) + "_" + triggeringItem.name.split('_').get(2) + "_"
    
    var root_toggle_item    = RoomLight_Virtual.members.findFirst[ i | i.name == root_name + "Toggle"]
    var root_adaptive1_item = RoomLight_Virtual.members.findFirst[ i | i.name == root_name + "Adaptive1"] //Stores json for channel 1
    var root_adaptive2_item = RoomLight_Virtual.members.findFirst[ i | i.name == root_name + "Adaptive2"] //stores json for channel 2
    var root_percent1_item  = RoomLight_Virtual.members.findFirst[ i | i.name == root_name + "Percent1"]  //slider for percentage channel 1
    var root_percent2_item  = RoomLight_Virtual.members.findFirst[ i | i.name == root_name + "Percent2"]
    var root_target1_item   = RoomLight_Virtual.members.findFirst[ i | i.name == root_name + "Target1"]   //Profile-targeted percent for channel 1
    var root_target2_item   = RoomLight_Virtual.members.findFirst[ i | i.name == root_name + "Target2"]

    //Assume the item's borked and reset - Toggle and dimmer sliders 
    if(root_toggle_item.state   == NULL){ root_toggle_item.postUpdate(OFF) }
    if(root_percent1_item.state == NULL){ root_percent1_item.postUpdate(0) }
    if(root_percent2_item.state == NULL){ root_percent2_item.postUpdate(0) }
    if(root_target1_item.state  == NULL){ root_target1_item.postUpdate(0) }
    if(root_target2_item.state  == NULL){ root_target2_item.postUpdate(0) }
    if(root_adaptive1_item.state  == NULL){ root_adaptive1_item.postUpdate('{"mode":"off","profile":"none"}') }
    if(root_adaptive2_item.state  == NULL){ root_adaptive2_item.postUpdate('{"mode":"off","profile":"none"}') }
    
    //Extract adaptive stuff values and fetch current targets
    var root_adaptive1_mode = "" + transform("JSONPATH", "$.mode", "" + root_adaptive1_item.state.toString)
    var root_adaptive2_mode = "" + transform("JSONPATH", "$.mode", "" + root_adaptive2_item.state.toString)
    var root_adaptive1_profile = "" + transform("JSONPATH", "$.profile", "" + root_adaptive1_item.state.toString)
    var root_adaptive2_profile = "" + transform("JSONPATH", "$.profile", "" + root_adaptive2_item.state.toString)

    //Determine if a toggle or a dimmer adjustment command was sent
    if(triggeringItem.name.contains("Toggle")){
        if(root_toggle_item.state == ON){
            if(root_adaptive1_mode == "on"){
                root_percent1_item.postUpdate(root_target1_item.state)
            }else{
                root_percent1_item.postUpdate(100)
            }
            if(root_adaptive2_mode == "on"){
                root_percent2_item.postUpdate(root_target2_item.state)
            }else{
                root_percent2_item.postUpdate(50)
            }
        }else{
            root_percent1_item.postUpdate(0)
            root_adaptive1_item.postUpdate('{"mode":"on","profile":"' + root_adaptive1_profile + '"}')
            root_adaptive2_item.postUpdate('{"mode":"on","profile":"' + root_adaptive2_profile + '"}')
        }
    }else{
        if(root_percent1_item.state > 0){
            root_toggle_item.postUpdate(ON)
        }else{
            root_toggle_item.postUpdate(OFF)
        }
        if(root_percent1_item.state != root_target1_item.state){
            //turn off adaptive mode
            root_adaptive1_item.postUpdate('{"mode":"off","profile":"' + root_adaptive1_profile + '"}')
        }
        if(root_percent2_item.state != root_target2_item.state){
            //turn off adaptive mode
            root_adaptive2_item.postUpdate('{"mode":"off","profile":"' + root_adaptive2_profile + '"}')
        }
    }
end

This rule enables the following behavior:

  1. When a light is turned on, if adaptive mode is on, it goes to that brightness and temperature value. If not, it goes to a default value (100% brightness, 50% temperature).
  2. If the brightness slider or temperature slider is manually adjusted via HabPanel or dimmer switch (eg the user wants it brighter at night), adaptive mode is turned off until the next time the light is toggled back on.

Secondly, there’s this rule which fetches the profile-based values and puts them into the target sliders:

rule "Light Control V2 - Adaptive Follower"
when
    Item RoomLight_DebugSwitch changed or
    Time cron "0 0/1 * * * ?"
then
    RoomLight_Watchdog.members.forEach[ root_item | 
        if(root_item.name.contains("Percent")){
            var root_name = "" + root_item.name.split('_').get(0) + "_" + root_item.name.split('_').get(1) + "_" + root_item.name.split('_').get(2) + "_"
            var root_toggle_item    = RoomLight_Virtual.members.findFirst[ i | i.name == root_name + "Toggle"]
            var root_adaptive_item = RoomLight_Virtual.members.findFirst[ i | i.name == root_item.name.replace('Percent','Adaptive')]
            var root_target_item   = RoomLight_Virtual.members.findFirst[ i | i.name == root_item.name.replace('Percent','Target')]
            var root_percent_item  = RoomLight_Virtual.members.findFirst[ i | i.name == root_item.name.replace('Percent','Percent')]
            var root_adaptive_mode    = "" + transform("JSONPATH", "$.mode", "" + root_adaptive_item.state.toString)
            var root_adaptive_profile = "" + transform("JSONPATH", "$.profile", "" + root_adaptive_item.state.toString)
            var Integer profile_target_current = 0
            var Integer profile_target_upcoming = 0

            if(root_adaptive_mode == 'on'){
                //fetch the profile and assign it to the target item
                var errorflag = true
                Profilestore_list_percentages.state.toString.split(',').forEach[ p | if(root_adaptive_profile == p){errorflag = false} ]
                if(errorflag){
                    logInfo("Light Control", "Invalid profile: " + root_adaptive_profile + " changing it to \"none\".")
                    root_adaptive_item.postUpdate('{"mode":"on","profile":"none"}')
                }else if(root_adaptive_profile != 'none'){
                    var profile_id_number = root_adaptive_profile.substring(root_adaptive_profile.indexOf("(")+1,root_adaptive_profile.indexOf(")"))
                    var item_profilestore_json = Profilestore_store.members.findFirst[ i | 
                        i.name == "Profilestore_json_" + profile_id_number
                    ]
                    profile_target_current  = Integer::parseInt(transform("JSONPATH", "$.intervals." + Timecontrol_CurrentInterval_Number.state + "", item_profilestore_json.state.toString))
                    profile_target_upcoming = Integer::parseInt(transform("JSONPATH", "$.intervals." + Timecontrol_NextInterval_Number.state + "",    item_profilestore_json.state.toString))
                }else{
                    profile_target_current = 100
                    profile_target_upcoming = 100
                }
                
                //Interpolate percentage target 
                var Number interpolatedvalue = profile_target_current + ((now.getMinuteOfHour() / 60.0 ) * (profile_target_upcoming - profile_target_current))
                var Number interpolatedint = interpolatedvalue.intValue

                //logInfo("Light Control", "Current target: " + profile_target_current + " Next target: " + profile_target_upcoming)
                //logInfo("Light Control", "interpolated value " + interpolatedint + ", set value: " + dimmer_target)

                root_target_item.postUpdate(interpolatedint)
                
                //Now, change any lights that are ON and adaptive
                if(root_toggle_item.state == ON){
                    root_percent_item.postUpdate(root_target_item.state)
                }
            }
        }  
    ]
end

These “Profilestore_Json_####” items generally contain a string like this:

{"label":"Adaptive Light Brightness","type":"percentage","intervals": {"0":12,"1":13,"2":13,"3":13,"4":11,"5":15,"6":33,"7":64,"8":81,"9":91,"10":99,"11":100,"12":99,"13":97,"14":97,"15":94,"16":92,"17":89,"18":85,"19":74,"20":57,"21":40,"22":30,"23":20}}

Where each hour, a value can be set (this gets linearly interpolated in the fetch rule). I also wrote a HabPanel widget to create/edit these JSON strings that looks like this:

And where the settings can be adjusted via popup:

All of this is handled in separate rules that deal with all the JSON string Items but that system is even more code and is also reused for heating control (the same JSON profile system also allows hourly heating plans for different thermostat zones).

Then, to link the actual physical inputs and outputs to the virtual light unit, each room has simple, but custom rules to do so. For example:

rule "Kitchen - Light Control - Virtual Interface - Ambient 1"
when
    Item RoomLight_Kitchen_Ambient1_Percent1 changed or
    Item RoomLight_Kitchen_Ambient1_Percent2 changed  
then
    logInfo("Kitchen Lighting", "Adjusting lights in Kitchen...")
    var coldBright = 0
    var warmBright = 0
    if(RoomLight_Kitchen_Ambient1_Percent1.state != 0){
        coldBright = RoomLight_Kitchen_Ambient1_Percent1.state as Number / 100 * (100 - RoomLight_Kitchen_Ambient1_Percent2.state as Number)
        warmBright = RoomLight_Kitchen_Ambient1_Percent1.state as Number / 100 * RoomLight_Kitchen_Ambient1_Percent2.state as Number
    }

    logInfo("Kitchen Ambient adjustment", "Coldbirght: " + coldBright + " Warmbright: " + warmBright)

    ShellyRGBWKitchenNorth1_Channel_0.sendCommand(coldBright)
    ShellyRGBWKitchenNorth1_Channel_2.sendCommand(coldBright)
    ShellyRGBWKitchenSouth1_Channel_0.sendCommand(coldBright)
    ShellyRGBWKitchenSouth1_Channel_2.sendCommand(coldBright)

    ShellyRGBWKitchenNorth1_Channel_1.sendCommand(warmBright)
    ShellyRGBWKitchenNorth1_Channel_3.sendCommand(warmBright)
    ShellyRGBWKitchenSouth1_Channel_1.sendCommand(warmBright)
    ShellyRGBWKitchenSouth1_Channel_3.sendCommand(warmBright)
end

rule "Kitchen - Light Control - Shelly interface - Ambient 1"
when
    Item ShellyKitchen1_Shortpress changed from CLOSED to OPEN 
then
    if(RoomLight_Kitchen_Ambient1_Toggle.state == ON){
        RoomLight_Kitchen_Ambient1_Toggle.sendCommand(OFF)
    }else{
        RoomLight_Kitchen_Ambient1_Toggle.sendCommand(ON)
    }
end

rule "Kitchen - Light Control - Shelly interface - Ambient 1 - Longpress"
when
    Item ShellyKitchen1_Longpress changed from CLOSED to OPEN 
then
    RoomLight_Kitchen_Ambient1_Percent2.postUpdate(50)
    RoomLight_Kitchen_Ambient1_Percent1.postUpdate(100)
end

These rules link the virtual items to their physical outputs (in this case a Shelly RGBW module) and links the physical inputs (a shelly module in a switch) to controll the lights.

Additionally, there are a bunch of simple HabPanel widgets I’ve written to control the lights for mobile devices:

Now, my question:

Programming this all was a real bear. Sure, the time-dependent profiles are used in other places like the heating system, and in the end, I’m still convinced that this approach saved net time when compared to manually writing extensive rules for each room and device. Still, this all leaves me wondering, is there a better/simpler way of doing this?

I mean, controlling smart lights is one of the core use cases of OH and I can’t imagine that I’m the only one who’d want to have their lights change according to the time of day. Is there a more native scalable solution to this? In general, I’m no expert-programmer and many of the things I did “feel” somewhat sketchy, like storing so much JSON data in string-type Items and so many virtual items in general. Also, while it works, it’s certainly nowhere near a on-click solution. If I want to add a new light, I need to copy/paste a much of item code–which while not difficult–does take time. Finally, the whole thing relies heavily on HabPanel widgets generating JSON text to write the profiles and such, which would be a lot of work to adapt to OH3’s UI.

Using Rules DSL this is a reasonable approach. I see a few little nit picks in the code but nothing that would change the over all approach.

One of the challenges is that Rules DSL is limited in a number of ways. Another challenge is you want to be able to modify most every parameter from the UI. Given that this is probably about as good as it gets.

If using one of the JSR223 rules languages you have a few more tools available to you, such as storing stuff into Item metadata instead of using virtual Items, ability to create libraries of functions, etc. But the hard part is the desire to be able to adjust it from the UI. That means you have to use Items and you can’t hard code much.

But anything like this which has so many possible adjustments is going to be pretty complicated.

I’ve seen a lot of approaches to solving this problem and most of them tend to either hard code or calculate on demand most of the things you have your JSON and sliders in HABPanel for. This makes things a whole lot simpler as you don’t need Items and you don’t need widgets. For example, instead of needing to manually set the color temp, base the color temp on the sun’s azimuth from the Astro binding.

A lot of users, myself included, completely separate the timing of events from the execution of the events. In this approach one sets up a time of day state machine so you have one Item that holds the current time of day as a String. That one Item is then used to trigger rules or used by rules to determine what time of day it is and perform the correct operation. See Design Pattern: Time Of Day. With the time of day I then have this really simple rule to drive all my lights:

triggers:
  - id: "1"
    configuration:
      itemName: TimeOfDay
    type: core.ItemStateChangeTrigger
conditions: []
actions:
  - inputs: {}
    id: "2"
    configuration:
      type: application/javascript
      script: >
        var logger =
        Java.type("org.slf4j.LoggerFactory").getLogger("org.openhab.model.script.TOD_Lights");


        var offGroupName = "TOD_Lights_OFF_" + items["TimeOfDay"];

        var onGroupName = "TOD_Lights_ON_" + items["TimeOfDay"];


        var switchLights = function(tod, grp, st) {
          logger.info("Turning " + st + " the lights for " + tod);
          ir.getItem(grp)
            .members
            .stream()
            .filter(function(light) { return light.state.toString() != st } )
            .forEach(function(light) { events.sendCommand(light.name, st) });
        }


        switchLights(items["TimeOfDay"], offGroupName, "OFF");

        switchLights(items["TimeOfDay"], onGroupName, "ON");
    type: script.ScriptAction

NOTE: That’s an OH 3 UI created rule using JavaScript as the Script Action. All it does though is look for a TOD_LIGHTS_OFF_<TOD> where <TOD> is the time of day state and turns all those off. Then it finds the ON group and turns those lights on. To change the which lights turn on when all I do is add/remove them from the given group.

But my requirements for light control are very simple. Just on and off. So this is about as simple as it will get. The more knobs you create to control them, the more complex it’s going to get.

Thanks for the feedback! It’s a real relief to hear that I didn’t go completely wrong somewhere.

As for all the UI, yeah, it adds complexity, but in my case it’s necessary–many of my users love to fiddle with and fine tune settings, but aren’t technically confident enough to go in and change code, especially when I’m not there to troubleshoot.

Do the OH3 yaml widgets have the same flexibility as the HabPanel ones–or more specifically, how would I go about transitioning over to them?

They are completely different. They are based on F7. Your widgets will need to be rewritten if you want to do the same thing in MainUI. But HABpanel is still very much supported. You don’t have to use MainUI as your end user’s interface. But if you do you’ll be starting over for these custom widgets.