Thermostat rules with manual override timeout

After finding nothing in the tutorials similar to my implementation, I decided to write a post about it.

I wanted to control the A/C based on time of day, but also allow a person to change the temperature on the thermostat, or in OpenHab.

Virtual setpoints for each air conditioner are set by time of day rules, manual changes at the thermostat or in OH change the virtual setpoint, and starts a timer. Changes to the virtual setpoint modify the Item setpoint.

items:

Number:Temperature vTNH "Temperature [%.1f °F]" (gVirtTemp) // virtual temperature north heating setpoint
Number:Temperature vTNC "Temperature [%.1f °F]" (gVirtTemp) // north cooling
Number:Temperature vTSH "Temperature [%.1f °F]" (gVirtTemp) // south heating
Number:Temperature vTSC "Temperature [%.1f °F]" (gVirtTemp) // south cooling

rules:

import java.util.Map

val Map<String, Timer> thermTimers = newHashMap
val Number northTimer = 120   // timeout to allow override at tstat
val Number southTimer = 240
val Number offMode = 0

// This lambda changes the virtual temps only when the thermostat mode is appropriate, and would cause a change in value.
val applyVirtualTemp = [Number tnh, Number tnc, Number tsh, Number tsc |
    val Number coolMode = 2
    val Number heatMode = 4 // 4-emergency heating strips
    logInfo("applyVirtualTemp", "");

    var currentMode = Thermostat_ThermostatMode.state as Number
    var h = vTNH.state as Number
    var c = vTNC.state as Number
    if ((currentMode == heatMode) && (h != tnh)){vTNH.sendCommand(tnh)}
    if ((currentMode == coolMode) && (c != tnc)){vTNC.sendCommand(tnc)}

    currentMode = ZWaveNode019SouthThermostat_ThermostatMode.state as Number
    h = vTSH.state as Number
    c = vTSC.state as Number
    if ((currentMode == heatMode) && (h != tsh)){vTSH.sendCommand(tsh)}
    if ((currentMode == coolMode) && (c != tsc)){vTSC.sendCommand(tsc)}
]

// The cron based rules for changing the temperature                            NH NC SH SC
rule "time01" when Time cron "0 0 0  ? * MON-FRI *" then applyVirtualTemp.apply(50,80,60,76); end
rule "time02" when Time cron "0 0 7  ? * MON-FRI *" then applyVirtualTemp.apply(70,76,70,76); end
rule "time03" when Time cron "0 0 10 ? * MON-FRI *" then applyVirtualTemp.apply(64,79,60,80); end
rule "time04" when Time cron "0 0 12 ? * MON-FRI *" then applyVirtualTemp.apply(65,78,60,80); end
rule "time05" when Time cron "0 0 16 ? * MON-FRI *" then applyVirtualTemp.apply(69,76,60,78); end
rule "time06" when Time cron "0 0 21 ? * MON-FRI *" then applyVirtualTemp.apply(50,78,60,76); end
rule "time07" when Time cron "0 0 0  ? * SAT,SUN *" then applyVirtualTemp.apply(50,80,60,76); end
rule "time08" when Time cron "0 0 6  ? * SAT,SUN *" then applyVirtualTemp.apply(69,76,70,76); end
rule "time09" when Time cron "0 0 10 ? * SAT,SUN *" then applyVirtualTemp.apply(65,76,50,80); end
rule "time10" when Time cron "0 0 12 ? * SAT,SUN *" then applyVirtualTemp.apply(65,76,50,80); end
rule "time11" when Time cron "0 0 21 ? * SAT,SUN *" then applyVirtualTemp.apply(50,76,50,80); end

rule "Virtual temp updated"
when
   // a group is more appropriate here
    Item vTNC changed or
    Item vTNH changed or
    Item vTSC changed or
    Item vTSH changed
then
    var setptItem = Thermostat_SetpointCooling
    if (triggeringItem.equals(vTNH)) {setptItem = Thermostat_SetpointHeating}
    if (triggeringItem.equals(vTSC)) {setptItem = ZWaveNode019SouthThermostat_SetpointCooling}
    if (triggeringItem.equals(vTSH)) {setptItem = ZWaveNode019SouthThermostat_SetpointHeating}

    // if a timer was not set for a setpoint, then set the setpoint to the virtual value
    if (thermTimers.get(setptItem.name) === null) {
        logInfo("Logger", setptItem.name + " thermostat timer was not set, setting to default.")
        setptItem.sendCommand(triggeringItem.state as Number);
    } else {
        logInfo("Logger", setptItem.name + " thermostat timer override, ignoring");
    }
end

rule "Thermostat was changed"
when 
    Item Thermostat_SetpointCooling changed or 
    Item Thermostat_SetpointHeating changed or
    Item ZWaveNode019SouthThermostat_SetpointCooling changed or 
    Item ZWaveNode019SouthThermostat_SetpointHeating changed
then
    var Number timeout = northTimer
    if (triggeringItem.name.contains("South")) {timeout = southTimer }

    var GenericItem vItem
    if (triggeringItem.equals(ZWaveNode019SouthThermostat_SetpointHeating)) {vItem = vTSH}
    if (triggeringItem.equals(ZWaveNode019SouthThermostat_SetpointCooling)) {vItem = vTSC}
    if (triggeringItem.equals(Thermostat_SetpointHeating))                  {vItem = vTNH}
    if (triggeringItem.equals(Thermostat_SetpointCooling))                  {vItem = vTNC}

    if (triggeringItem.state as Number != vItem.state as Number) {
        logInfo("ThermostatWasChanged", "Setpoint and virtual temps are different, starting timer")
        thermTimers.get(triggeringItem.name)?.cancel
        thermTimers.put(triggeringItem.name, createTimer(now.plusMinutes(timeout), [ |
            logInfo("ThermostatWasChanged", "Timer is finished.")
            var GenericItem vItem1
            if (triggeringItem.equals(ZWaveNode019SouthThermostat_SetpointHeating)) {vItem1 = vTSH}
            if (triggeringItem.equals(ZWaveNode019SouthThermostat_SetpointCooling)) {vItem1 = vTSC}
            if (triggeringItem.equals(Thermostat_SetpointHeating))                  {vItem1 = vTNH}
            if (triggeringItem.equals(Thermostat_SetpointCooling))                  {vItem1 = vTNC}

            triggeringItem.sendCommand(vItem1.state as Number)
            thermTimers.remove(triggeringItem.name)
            logInfo("ThermostatWasChanged", triggeringItem.name + " timed out, and setpoint temperature set to default.")
        ]))
    } else {
        logInfo("ThermostatWasChanged", "Setpoint and virtual temps are equal")
    }
end

Improvements to this would be to create appropriate groups, and have a hashmap lookup of item to item, which would reduce code duplication.

Have you considered applying Design Pattern: Time Of Day? Keep in mind that the example there is just for reference and can be and is intended to be expanded to include days of the week and such. That could replace the cron triggered Rules and potentially give you something more flexible that could be expanded where you can, for example, define the times based on a calendar.

Also look at Design Pattern: Associated Items for a way to make the temps for each time period stored in Items that can then be adjusted from the sitemap instead of having to hard code them.

I use a similar approach in my lighting. During the day, the lights will come on when it’s cloudy. But if a user turns on or off a light during this time, that light will keep that state and no longer change based on cloudiness. The light then becomes automated again when the next time period comes along. This kind of approach where the override lasts until the next time period would vastly simplify these Rules as you would no longer need timers at all.

I’ve moved this to the Tutorials and Examples Solutions category as it’s more appropriate.

I did consider your Time Of Day pattern, but my personal choice is to not have enumerated (or role based) “scenes”, but instead have an infinite number of cron based triggers that would set the temperature.

I do use your Associated Items design pattern elsewhere, and this would also be a good approach to reducing code clutter.

Initially, my cron rules overwrote the settings in the thermostat, but sometimes that is a short duration for some activities. A timer seemed like a better approach to this problem.

I don’t really see how Time of Day limits you in this way. For example, take my lighting Rule:

val logName = "lights"

// Theory of operation: Turn off the light that are members of gLights_OFF_<TOD> and
// then turn on the lights that are members of gLights_ON_<TOD>. Reset the overrides.
rule "Set lights based on Time of Day"
when
  Item vTimeOfDay changed
then
  // reset overrides
  gLights_WEATHER_OVERRIDE.postUpdate(OFF)

  val offGroupName = "gLights_OFF_"+vTimeOfDay.state.toString
  val onGroupName = "gLights_ON_"+vTimeOfDay.state.toString

  logInfo(logName, "Turning off lights for " + offGroupName)
  val GroupItem offItems = gLights_OFF.members.filter[ g | g.name == offGroupName ].head as GroupItem
  offItems.members.filter[ l | l.state != OFF ].forEach[ SwitchItem l | l.sendCommand(OFF) ]

  logInfo(logName, "Turning on lights for " + onGroupName)
  val GroupItem onItems = gLights_ON.members.filter[ g| g.name == onGroupName ].head as GroupItem
  onItems.members.filter[ l | l.state != ON].forEach[ SwitchItem l | l.sendCommand(ON) ]

end

If I want to add more times of day I just need to add them to the Rule and add two new Groups for each new time of day state I’ve created. That’s also why I linked to the Assocaited Items DP above. I think you’d only need one new Group per setting.

This is the sort of thing I was thinking of in my reply.