Controlling the CT-30 Thermostat


With all the work I've done to set up a thermostat under the control of a KRL program, controlling the thermostat from Google calendar is quite simple. This post discusses the rules that make the thermostat work.

In my last blog post on the CT-30 thermostat project, I discussed how to talk back to the thermostat using it's API, how to express that in KRL, and the endpoint that ties the two together. In this post, we tie everything together and start controlling the thermostat.

Programming the Thermostat

My goal has been to be able to control the thermostat from a Google calendar. I find scheduling tasks much easier with a calendar interface and Google's is convenient and has an API. What's more, I can create as many calendars as I want, so I could have one for each HVAC zone. As you can see from the following screenshot, the calendar simple has entries for "Temperature" followed by an acceptable temperature range, such as "71-77."

ct-30 calendar

I used Sam Curren's Google calendar module to access the calendar. The module provides a function, now() that, given a regular expression, returns the JSON structure of the current event, if the regular expression matches the event title.

The rule get_temperature_target looks at the calendar whenever there's a thermostat:new_temperature event and calculates the maximum and minimum temperature targets. We have a noop() action so that we can use the condition (max_temp && min_temp) to guard the statements in the postlude. Once the rule has grabbed the maximum and minimum temperatures out of the calendar, it will know what they are even if there's no current event on the calendar with "Temperature" in its title because we put them in entity variables. Once the temperature range is set, it stays the same until reset.

rule get_temperature_target {
  select when thermostat new_temperature
  pre {
    event_detail = gcal:now("/Temperature/");
    temp_title = event_detail.pick("$..title");
    target_temps = temp_title.extract(re/(\\d\\d)\\s*-\\s*(\\d\\d)/);
    max_temp = target_temps[1];
    min_temp = target_temps[0];
  if(max_temp && min_temp) then noop();
  fired {
    set ent:max_temp_target max_temp;
    set ent:min_temp_target min_temp;
    raise explicit event new_target_temperature 
      with max_temp = max_temp
       and min_temp = min_temp
       and mode = event:attr("mode");
    log "Target temperature: #{min_temp} - #{max_temp}";

Recall that the thermostat:new_temperature is raised by the thermostat endpoint regularly (once a minute), so the thermostat is constantly calling home to see if there's a new target temperature. In production, this could probably be adjusted to every five minutes or even more since the calendar only works in five minute increments itself.

The get_temperature_target rule raises the explicit event new_target_temperature if it has a new target temperature so that we can control the thermostat.

Controlling the Thermostat

The CT-30 thermostat doesn't support using a temperature range directly. There's no "auto" mode that just keeps the temperature within the range. It has to be either "off" or in "heat" or "cool" mode. That's ok, because we can easily program that up ourselves with KRL.

The rule set_target_and_mode responds to an explicit:new_target_temperature event and uses the temperature range we set with get_temperature_target rule to control the thermostat. To support the range, it has to determine if the thermostat should be in "heat" or "cool" mode as well as determine the appropriate target temperature. Both of these calculations use the current temperature. We do the comparisons against the maximum and minimum temperatures adjusted with a bound (currently equal to 1) to expand the range slightly to avoid ranges that are too narrow.

rule set_target_and_mode {
  select when explicit new_target_temperature
  pre {
    curr_temperature = event:attr("temperature");
    max_temp = event:attr("max_temp");
    min_temp = event:attr("min_temp");
    mode = (curr_temperature > max_temp+bound) => "cool" |
           (curr_temperature < min_temp-bound) => "heat" |
\t\t\t\t\t\t  "no_change";
    temperature = (mode eq "cool") => max_temp |
                  (mode eq "heat") => min_temp |
    if(mode neq ent:curr_mode || 
       temperature neq ent:curr_target) then { 
      radstat:set_temperature(mode, temperature);
    fired {
      set ent:curr_mode mode; 
      set ent:curr_target temperature; 
      raise explicit event updated_thermostat
       with mode = mode
        and target_temp = temperature;
    log "Setting mode to #{mode} for temperature #{temperature}"

The rule only fires if the mode changes or temperature to avoid constantly updating the thermostat. The rule uses the set_temperature() action to set the mode and target temperature. The postlude stores the new mode and target and raises an explicit event updated_thermostat_target so that any downstream rules can listen and update their behavior, if necessary.


There are a few improvements I could make to the code:

  • I don't do any range checking to ensure that the maximum is really bigger than the minimum.
  • I expand the range indiscriminately, whether it's too narrow or not.

These aren't hard and I'll probably get them done this afternoon. Then the thermostat will be ready to installation so that it can start its beta test phase.