Wednesday, February 27

Hack My House: Raspberry Pi as a Touchscreen Thermostat

Your thermostat is some of the oldest and simplest automation in your home. For years these were one-temperature setting and nothing more. Programmable thermostats brought more control; they’re alarm clocks attached to your furnace. Then Nest came along and added beautiful design and “learning features” that felt like magic compared to the old systems. But we can have a lot more fun. I’m taking my favorite single-board computer, the Raspberry Pi, and naming it keeper of heat (and cool) by building my own touchscreen thermostat.

Mercury thermostats started it all, and were ingenious in their simplicity — a glass capsule containing mercury, attached to a wound bi-metal strip. As the temperature changes, the contraption tilts and the mercury bead moves, making or breaking contact with the wiring. More sophisticated thermostats have replaced the mercury bead with electronics, but the signaling method remains the same, just a simple contact switch.

This makes the thermostat the prime target for an aspiring home automation hacker. I’ve had this particular project in mind for quite some time, and was excited to dive into it with simple raw materials: my Raspberry Pi, a touchscreen, and a mechanical relay board.

Hot Wiring a Heater

If you replace your standard home thermostat you find the most common setup has either 4 or 5 wires running to your HVAC equipment. These include a 24 volt AC power wire, leads to switch the heater, air conditioner, and fan, and finally an optional “common” wire, which is often used to power a smart thermostat. In order to remain backwards compatible, virtually all residential HVAC units in my part of the world use a version of this layout. A common troubleshooting technique is to “hot wire” an HVAC system — directly connecting the 24 volt wire to either the heater line or the the AC line.

For the hacker, the takeaway is that a simple relay is perfect to drive the system. For my setup shown above, I bridge the red 24 volt line to the yellow heater line, and the system roars to life. I’m using the 4 channel relay module from SainSmart. Anything that has GPIO and can talk to a temperature sensor is enough to build a thermostat. As you all know, however, I have committed to a building a Raspberry Pi into every room in my house, and I’m using all that extra power to run the official 7 inch touchscreen as a display and interface for the HVAC. I’m also using some Adafruit MCP9808 temperature sensors, which talk to our Pis using the I2C bus.

I2C Gotcha: Never Cross the Streams

Partway through the build, I did run into a very strange problem. After a few minutes of working perfectly, the temperature sensor began returning 0C, the touchscreen stopped responding to touches, and i2cdetect thought there was an i2c device at every address. I knew the touchscreen and temperature sensor were sharing the I2C bus, so I began troubleshooting what was causing that bus to hang.

The display has 4 pins and a ribbon cable. Those pins are power, ground, and the two I2C pins. When connecting an original Raspberry Pi A or B, those I2C pins need to be wired to the Pi’s single I2C bus. Starting with the Pi A+ and B+, there is a second I2C bus dedicated to the display, physically connected through the ribbon cable. I was unknowingly connecting the display to both I2C buses, not to mention bridging the two buses together. When they happened to both talk at once, both went down. TLDR: Only connect the two dedicated power pins and the ribbon cable, not the I2C pins on the display.

Temperature Monitoring with Python and Flask

Last time, we used Python and Flask to send requests to the Raspberry Pi wired to the garage door. We’re expanding on that idea to build an HTTP interface for the Thermostat Pi as well. An HTTP request to the correct path will return the detected temperature value. Readers have pointed out the possibility of overheating the Raspberry Pis, so I’ve also added the Pis’ CPU temperatures to the list of monitored temperatures.

from flask import Flask
import smbus
import os
import time
import RPi.GPIO as GPIO
app = Flask(__name__)
GPIO.setmode(GPIO.BCM)
GPIO.setup(17, GPIO.OUT, initial=GPIO.HIGH)
GPIO.setup(18, GPIO.OUT, initial=GPIO.HIGH)
GPIO.setup(27, GPIO.OUT, initial=GPIO.HIGH)
bus = smbus.SMBus(1)
config = [0x00, 0x00]
bus.write_i2c_block_data(0x18, 0x01, config)

@app.route("/enable/<pin>")
def enable(pin):
        GPIO.output(int(pin), GPIO.LOW)
@app.route("/disable/<pin>")
def disable(pin):
        GPIO.output(int(pin), GPIO.HIGH)
@app.route("/temp/<sensor>")
def temp(sensor):
        if sensor == "internal" :
                temp = os.popen("vcgencmd measure_temp").readline()
                ctemp = float(temp.replace("temp=","").replace("'C",""))
                return str(ctemp * 1.8 + 32)
        if sensor == "external" :
                bus.write_byte_data(0x18, 0x08, 0x03)
                time.sleep(0.5)
                data = bus.read_i2c_block_data(0x18, 0x05, 2)
                ctemp = ((data[0] & 0x1F) * 256) + data[1]
                if ctemp > 4095 :
                        ctemp -= 8192
                ctemp *= 0.0625
                ftemp = ctemp * 1.8 + 32
                return str(ftemp)
if __name__ == "__main__":
        app.run(host='0.0.0.0', port=80, debug=False)

As you can see above, we’ve exposed the two temperatures as part of our RESTful interface. Now that we have access to that data, what do we do with it? Enter RRDTool.

Round-Robin Databases and Pretty Graphs

You may not be familiar with the name, but you’ve probably seen graphs produced by RRDTool, most notably in the Cacti monitoring suite. RRDTool is a simple round-robin database built on creating pretty graphs, and the idea that older data needs less resolution than fresh data. It might be useful to track temperature minute-by-minute, but usually only the last couple hours of that data. Last week’s data doesn’t need to be as granular: an average temperature for each hour might be enough. Last month, you might just care about the daily averages, etc. RRDTool lets you specify multiple round robin archives for each data source, with different time spans and granularity.

One more trick I’ve made use of is specifying a data source to track when the heater or air conditioner is running. This allows comparing temperatures to the HVAC duty cycle, which is useful for tracking down insulation and efficiency issues. Also, this data will be important for tuning the thermostat to avoid “short cycles”, when the system doesn’t run long enough to reach full efficiency, but turns on and off several times in short succession.

Stitching Together All The Parts That Make an Automatic Thermostat

Now that we’ve sorted the connection to the heater, temperature monitoring, and database, it’s time to put them all together. I’ve opted for a one-minute cycle: polling all our data sources, recording that data, and running the heater control logic every 60 seconds. To avoid short cycling, there is a temperature width setting — you could call it the system hysteresis. I’ve settled on a four degree swing: The thermostat turns on once the observed temperature drops two degrees below the target, and runs until it’s raised two degrees above it. This is the great thing about rolling your own system: you get to decide exactly how it will work.

import time
from rrdtool import update as rrd_update
import pycurl
import json
from StringIO import StringIO
starttime=time.time()
tempSensors = ("thermostat-temp-external", "thermostat-temp-internal", "office-temp-external", "office-temp-internal",
               "office-temp-outside", "garage-temp-internal", "garage-temp-external", "livingroom-hum-external",
               "livingroom-temp-external", "livingroom-temp-internal", "office-hum-outside")
#settings = {"temp": 70, "mode": "heat", "heater-width": 2, "ac-width": 2}
c = pycurl.Curl()
c.setopt(c.URL, 'http://thermostat/disable/17')
c.perform()
c.setopt(c.URL, 'http://thermostat/disable/27')
c.perform()
c.close()
state = {"activity": "idle"}
while True:
        with open('/var/www/data/settings', 'r') as f:
                settings = json.load(f)
        f.closed
        temperatures = {}
        c = pycurl.Curl()
        for sensor in tempSensors:
                try:
                        buffer = StringIO()
                        c.setopt(c.URL, 'http://' + sensor.replace("-", "/")) #change dashes to /
                        c.setopt(pycurl.WRITEFUNCTION, buffer.write)
                        c.perform()
                        temperatures[sensor] = float(buffer.getvalue())
                        rrd_update('/var/www/data/' + sensor + '.rrd', 'N:%f' %(temperatures[sensor]))
                except Exception as e:
                        print(e)
        with open('/var/www/data/temps', 'w') as f:
                json.dump(temperatures, f)
        f.closed
        if settings["mode"] == "heat": #if mode heat (auto should compare to the outside temp, to figure out heat or AC
                if state["activity"] == "idle" and temperatures["thermostat-temp-external"] < (settings["temp"] - (settings["heater-width"] / 2.0)) :
                        c.setopt(c.URL, 'http://thermostat/enable/17')
                        c.perform()
                        c.close()
                        state["activity"] = "heating"
                elif state["activity"] == "heating" and temperatures["thermostat-temp-external"] > (settings["temp"] + (settings["heater-width"] / 2.0)) :
                        c.setopt(c.URL, 'http://thermostat/disable/17')
                        c.perform()
                        c.close()
                        state["activity"] = "idle"
        with open('/var/www/data/state', 'w') as f:
                json.dump(state, f)
        f.closed
        if state["activity"] == "heating" :
                rrd_update('/var/www/data/heater-state.rrd', 'N:100')
        else :
                rrd_update('/var/www/data/heater-state.rrd', 'N:0')
        if state["activity"] == "cooling" :
                rrd_update('/var/www/data/ac-state.rrd', 'N:100')
        else :
                rrd_update('/var/www/data/ac-state.rrd', 'N:0')
        time.sleep(60.0 - ((time.time() - starttime) % 60.0))

Touchscreens: You’re Going to Want a GUI

All that’s left is the user interface. The actual hardware is a Raspberry Pi 3 B+, booted over PXE, with the official 7 inch touchscreen, mounted on a 3-gang wall box. For software, we’re using Chromium in fullscreen mode, and building a webpage optimized for the Pi display’s small size. You may remember when wiring in the garage door opener, we put a single button on a web page. Today we’re expanding that page to make a central control panel.

<?php
  $settings = json_decode(file_get_contents('/var/www/data/settings'));
  $temps = json_decode(file_get_contents('/var/www/data/temps'));
  $state = json_decode(file_get_contents('/var/www/data/state'));
  if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if ($_POST["up"]) {
      $settings->{'temp'} += 1;
    }
    if ($_POST["down"]) {
      $settings->{'temp'} -= 1;
    }
    if ($_POST["heat"]) {
      $settings->{'mode'} = "heat";
    }
    if ($_POST["cool"]) {
      $settings->{'mode'} = "cool";
    }
    if ($_POST["auto"]) {
      $settings->{'mode'} = "auto";
    }
    if ($_POST["off"]) {
      $settings->{'mode'} = "off";
    }
    if ($_POST["GDO"]) {
      $curl_handle = curl_init();
      curl_setopt( $curl_handle, CURLOPT_URL, 'http://garage/moment/20' );
      curl_exec( $curl_handle ); // Execute the request
      curl_close( $curl_handle );
    }
    $file = fopen('/var/www/data/settings', "w") or die("Unable to open file!");
    fwrite($file, json_encode($settings, JSON_NUMERIC_CHECK));
  }
?>
<!DOCTYPE html>
<html>
<head>
 <meta http-equiv="refresh" content="60">
 <script src="functions.js"></script>
 <script>
  function startTime() {
   var today = new Date();
   document.getElementById('txt').innerHTML = formatDate(today, "dddd h:mm:ss TT d MMM yyyy");
   var t = setTimeout(startTime, 500);
  }
 </script>
</head>
<body onload="startTime()">
 <div style="width:790px; margin:auto;">
  <div style="height:140px;">
   <div style="float:left;">
    <form method="post">
     <input type="submit" name="up" value="▲" style="padding:25px 25px;">
    </form>
    <form method="post">
     <input type="submit" name="down" value="▼" style="padding:25px 25px;">
    </form>
   </div>
   <div style="float:left; margin-left:5px; margin-top:60px;">
    Thermostat set to: <?php  echo $settings->{'temp'}; ?>
   </div>
   <div style="float:left; margin-left:25px;">
    <div id="txt"></div>
    Inside: <?php echo round($temps->{'thermostat-temp-external'}, 2); ?>
    <br> Outside: <?php echo round($temps->{'office-temp-outside'}, 2); ?>
    <br> System is: <?php echo $state->{'activity'}; ?>
   </div>
   <div style="float:right;">
    <form method="post">
     <input type="submit" name="cool" value="cool" style="height:70px; width:100px;<?php if($settings->{'mode'} == "cool"){echo "color:red;";}?>">
    </form>
    <form method="post">
     <input type="submit" name="off" value="off" style="height:70px; width:100px;<?php if($settings->{'mode'} == "off"){echo "color:red;";}?>">
    </form>
   </div>
   <div style="float:right;">
    <form method="post">
     <input type="submit" name="heat" value="heat" style="height:70px; width:100px;<?php if($settings->{'mode'} == "heat"){echo "color:red;";}?>">
    </form>
    <form method="post">
     <input type="submit" name="auto" value="auto" style="height:70px; width:100px;<?php if($settings->{'mode'} == "auto"){echo "color:red;";}?>">
    </form>
   </div>
  </div>
  <div style="margin:auto; text-align:center;">
<?php
  $opts = array ( "-w", "709", "-h", "200", "-Y",
    '-a', "PNG", "--start=-14400","--end=now",
    'DEF:heater-state=/var/www/data/heater-state.rrd:state:AVERAGE',
    'AREA:heater-state#FF000050:"Heater usage"',
    'DEF:air-state=/var/www/data/ac-state.rrd:state:AVERAGE',
    'AREA:air-state#0000FF50:"air usage"',
    'DEF:livingroom-hum=/var/www/data/livingroom-hum-external.rrd:humidity:AVERAGE',
    'LINE1:livingroom-hum#000000:humidity',
    'DEF:therm-temp=/var/www/data/thermostat-temp-external.rrd:temperature:AVERAGE',
    'LINE1:therm-temp#0000FF',
    'DEF:outside-temp=/var/www/data/office-temp-outside.rrd:temperature:AVERAGE',
    'LINE1:outside-temp#FF0000'
    );
  $graphObj = new RRDGraph('-');
  $graphObj->setOptions($opts);
  try {
    $ret = $graphObj->saveVerbose();
  } catch (Exception $e) {
    echo 'Caught exception: ',  $e->getMessage(), "\n";
    echo rrd_error()."\n";
  }
  if(!$ret){
    echo rrd_error()."\n";
  } else {
    #var_dump($ret);
    echo '<img alt="My Image" src="data:image/png;base64,' . base64_encode($ret['image']) . '" />';
  }
?>
  </div>
  <div style="float:left;">
   <form method="post">
    <input type="submit" name="GDO" value="Cycle Garage" style="padding:25px 15px; margin-right:25px;">
   </form>
  </div>
</body>
</html>

Most of it is straightforward PHP and HTML. The most interesting element is the way the RRDTool graphs are dynamically generated at page load, and included in the html document. This allows future customization, like the ability to zoom out and see older data, or select other data sources to include.

The 3D printed mount finishes the project nicely. It’s rather important to get that temperature sensor away from the heat of the Pi, in order to get an accurate reading.

We have more to come, so keep your eyes peeled, and feel free to follow me over on twitter for the occasional sneak peak or suggetions for the next step in my home automation adventure!

No comments:

Post a Comment