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:
- 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).
- 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.