Thursday 16 June 2016

Radpi #4: boiler control

With the ability to control the boiler remotely and the temperature sensor data stored in a database, I could start adjusting the temperature in the house.
Back to the boiler, we need to control when to start/stop it. I've decided to make that decision based on the temperature of 2 rooms (but more would be easy to use, the other rooms I monitor being separate from the house they weren't relevant). Periodically, a script runs that load the last minute of data from the pis of interest, and based of their values and a program, decides to start/stop the boiler.

I also wanted the controller pi to tell me what's happening. So it will send emails when it starts/stops the boiler and when it can't do anything for some reason (e.g. no recent temperature readings, or the script is disabled).

First, the boiler schedule. I created a file with time ranges and temperature targets. My strategy was that the livingroom is the main room, and the bedroom upstairs would act as a moderator (you don't want the temperature to be too high there). The configuration of targets is as follows:

$ranges = Array(
                Array("start" => "00:00", "end" => "06:00", "target" => 18.5, "bedroommax" => 21.5, "bedroommin" => 19),
                Array("start" => "06:00", "end" => "07:30", "target" => 21, "bedroommax" => 21.5, "bedroommin" => 19),
                Array("start" => "07:30", "end" => "16:00", "target" => 20.5, "bedroommax" => 21.5, "bedroommin" => 19),
                Array("start" => "16:00", "end" => "17:30", "target" => 21, "bedroommax" => 21.5, "bedroommin" => 19),
                Array("start" => "17:30", "end" => "23:59:59", "target" => 18.5, "bedroommax" => 21.5, "bedroommin" => 19)
        );

$maxdeviation = 0.5;
$adjust = -0.0;

The target temperature applies to the main room (the livingroom) and the bedroom as a fuse that can't be exceeded. The $maxdeviation value refers to the leeway allowed around the target temperature in the livingroom (we don't want to start/stop every minute, so we allow things to vary a bit). $adjust can be used in case the temperature sensor (they're not all very accurate) or its location (e.g. in a corner) distort the temperature and it needs to be adjusted to reflect reality better.

Now, here are the boiler control script. You'll recognise the wemo control code and  the DB access.
<?php

include_once("config.inc");
include_once('SMTPconfig.php');
include_once('SMTPClass.php');

$portindex = 0;

// Find the target range that applies
function findRange() {
global $ranges;

foreach($ranges as $range) {
if (time() >= strtotime(date("d-M-Y ".$range["start"])) && time() <= strtotime(date("d-M-Y ".$range["end"]))) {
return $range;
}
}
}

// Read the livingroom temperature from the DB
function readTemperature() {
global $dbhost, $dbuser, $dbpass, $dbdb;

$res1 = mysql_connect($dbhost, $dbuser, $dbpass); 
mysql_select_db($dbdb); 
$result = mysql_query("select ComputerTime,Temperature,Humidity from TempHumid where ComputerTime >= (unix_timestamp(now())-(60*3)) order by ComputerTime desc limit 1", $res1); 
if ($result) {
$row = mysql_fetch_assoc($result);
if ($row["Temperature"] < 40) { 
return $row;
}
} else {
print("No data read\n");
}
mysql_close($res1); 
}

// Read the bedroom temperature from the DB
function readBedroomTemperature() {
        global $dbhost, $dbuser, $dbpass, $dbdb;

        $res1 = mysql_connect($dbhost, $dbuser, $dbpass);
        mysql_select_db($dbdb);
        $result = mysql_query("select ComputerTime,Temperature from Bedroom where ComputerTime >= (unix_timestamp(now())-(60*3)) order by ComputerTime desc limit 1", $res1);
        if ($result) {
                $row = mysql_fetch_assoc($result);
                if ($row["Temperature"] < 40) {
                        return $row;
                }
        } else {
                print("No data read\n");
        }
        mysql_close($res1);
}

// Turn wemo off
function wemooff() {
global $portindex, $server, $ports;

   $headers = array(
            "Accept: ",
            "Content-type: text/xml;charset=\"utf-8\"",
            "SOAPACTION: \"urn:Belkin:service:basicevent:1#SetBinaryState\""
        );

   $post = '<?xml version="1.0" encoding="utf-8"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
   s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:SetBinaryState
   xmlns:u="urn:Belkin:service:basicevent:1"><BinaryState>0</BinaryState></u:SetBinaryState></s:Body></s:Envelope>';

   $url1 = $server.":".$ports[$portindex]."/upnp/control/basicevent1";


    $defaults = array(
   CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_0,
   CURLOPT_USERAGENT => '',
   CURLOPT_POSTFIELDS => $post,
   CURLOPT_HTTPHEADER => $headers,
    CURLOPT_POST => 1,
    CURLOPT_HEADER => 0,
    CURLOPT_URL => $url1,
    CURLOPT_FRESH_CONNECT => 1,
    CURLOPT_RETURNTRANSFER => 1,
    CURLOPT_FORBID_REUSE => 1,
    CURLOPT_TIMEOUT => 4,

    );
    $ch = curl_init();
    curl_setopt_array($ch, $defaults);
   $result = curl_exec($ch);
    if( ! $result = curl_exec($ch))
    {
        trigger_error(curl_error($ch));
    }

    curl_close($ch);
}

// Turn wemo on
function wemoon() {
global $portindex, $server, $ports;

        $headers = array(
            "Accept: ",
            "Content-type: text/xml;charset=\"utf-8\"",
       "SOAPACTION: \"urn:Belkin:service:basicevent:1#SetBinaryState\""
        );

   $post = '<?xml version="1.0" encoding="utf-8"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:SetBinaryState xmlns:u="urn:Belkin:service:basicevent:1"><BinaryState>1</BinaryState></u:SetBinaryState></s:Body></s:Envelope>';

   $url1 = $server.":".$ports[$portindex]."/upnp/control/basicevent1";

    $defaults = array(
   CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_0,
   CURLOPT_USERAGENT => '',
   CURLOPT_POSTFIELDS => $post,
   CURLOPT_HTTPHEADER => $headers,
    CURLOPT_POST => 1,
    CURLOPT_HEADER => 0,
    CURLOPT_URL => $url1,
    CURLOPT_FRESH_CONNECT => 1,
    CURLOPT_RETURNTRANSFER => 1,
    CURLOPT_FORBID_REUSE => 1,
    CURLOPT_TIMEOUT => 4,

    );
    $ch = curl_init();
    curl_setopt_array($ch, $defaults);
   $result = curl_exec($ch);
    if( ! $result = curl_exec($ch))
    {
        trigger_error(curl_error($ch));
    }

    curl_close($ch);
}

// Get the state of the wemo
function wemostate() {
global $portindex, $server, $ports;

        $headers = array(
            "Accept: ",
            "Content-type: text/xml;charset=\"utf-8\"",
       "SOAPACTION: \"urn:Belkin:service:basicevent:1#GetBinaryState\""
        );

   $post = '<?xml version="1.0" encoding="utf-8"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:GetBinaryState xmlns:u="urn:Belkin:service:basicevent:1"><BinaryState>1</BinaryState></u:GetBinaryState></s:Body></s:Envelope>';

   $powerstate = "";

   $portindex = 0;
   while (trim($powerstate)=="" && $portindex<count($ports)) {

        $url1 = $server.":".$ports[$portindex]."/upnp/control/basicevent1";


        $defaults = array(
        CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_0,
        CURLOPT_USERAGENT => '',
        CURLOPT_POSTFIELDS => $post,
        CURLOPT_HTTPHEADER => $headers,
        CURLOPT_POST => 1,
        CURLOPT_HEADER => 0,
        CURLOPT_URL => $url1,
        CURLOPT_FRESH_CONNECT => 1,
        CURLOPT_RETURNTRANSFER => 1,
        CURLOPT_FORBID_REUSE => 1,
        CURLOPT_TIMEOUT => 4,
        );


        $ch = curl_init();
        curl_setopt_array($ch, $defaults);

        $powerstate = curl_exec($ch);
        if (trim($powerstate)=="") {
                $portindex++;
        }
   }

// isolate the status byte
   $power = substr($powerstate, stripos($powerstate,"BinaryState>") + strlen("BinaryState>"),1);

   $status = "unknown" ;
   if ( $power == "0" )
      { $status = "off" ; }
   else if ( $power == "1" )
         { $status = "on" ; };

    if( ! $powerstate == curl_exec($ch))
    {
        trigger_error(curl_error($ch));
    }

    curl_close($ch);
    return $status;
}

// Adjust the temperature reading for error inaccuracy.
function adjustForError($temp) {
global $adjust;

return $temp + $adjust;
}

// Send notification email.
function notification($message) {
global $SmtpServer, $SmtpPort, $SmtpUser, $SmtpPass;

$to = "cedric@raguenaud.org";
$from = "boiler@raguenaud.org";
$subject = "Boiler update";
$body = $message;
$SMTPMail = new SMTPClient ($SmtpServer, $SmtpPort, $SmtpUser, $SmtpPass, $from, $to, $subject, $body);
$SMTPChat = $SMTPMail->SendMail();
}

// Let's do magic!
$range = findRange();
if (!isset($range)) {
// Couldnt' find a temperature target. This is wrong. Notify and exit.
notification("No applicable range found.");
die ("No applicable range found.");
}
print("Target is ".$range["target"]."\n");

// Get temperatures.
$temp = readTemperature();
$bedroom = readBedroomTemperature();

// If we can't find a recent temperature, notify and exit
if (!isset($temp)) {
notificationn("No temperature found.");
die ("No temperature found.");
}

// Log the data to file
print("At ".date("d-M-Y H:i:s", $temp["ComputerTime"]).", temperature was ".adjustForError($temp["Temperature"])." and humidity was ".$temp["Humidity"].". Bedroom was ".$bedroom["Temperature"].".\n");

// What is the state of the wemo/boiler?
$status = wemostate();
print("Boiler is ".$status."\n");
if ($status == "unknown") {
die("Can't read boiler status.\n");
}

// Bad temperature values were stored. Ignore and exit.
if ($temp["Temperature"]==0 || $temp["Temperature"]>40) {
notification("No decent temperature found. th it probably not running.");
die();
}

if (
(
adjustForError($temp["Temperature"] < ($range["target"]-$maxdeviation)
|| $bedroom["Temperature"] < ($range["bedroommin"]-$maxdeviation)

&& $bedroom["Temperature"] < $range["bedroommax"] && $status == "off") && !file_exists("/tmp/donothing.lock")) {
// start boiler
print("Starting boiler.\n");
notification("Starting boiler. Temperature was ".adjustForError($temp["Temperature"]).". Humidity was ".$temp["Humidity"].". Bedroom was ".$bedroom["Temperature"].".\n");
wemoon();
} else
if ((
(adjustForError($temp["Temperature"]) >= $range["target"] && $bedroom["Temperature"] > $range["bedroommin"]) || $bedroom["Temperature"] >= $range["bedroommax"]) && $status == "on" && !file_exists("/tmp/donothing.lock")) {
        // stop boiler
print("Stopping boiler.\n");
notification("Stopping boiler. Temperature was ".adjustForError($temp["Temperature"]).". Humidity was ".$temp["Humidity"].". Bedroom was ".$bedroom["Temperature"].".\n");
wemooff();
} else {
//notification("Nothing to do. Temperature was ".adjustForError($temp["Temperature"]).". Humidity was ".$temp["Humidity"].".\n");
print("Nothing to do.\n");
if (file_exists("/tmp/donothing.lock")) {
print("DONOTHING is on.\n");
if (rand(0, 9)==0) {
notification("DONOTHING is on.\n");
}
}
}

?> 

By creating a file called /tmp/donothing.lock you disable the boiler. It's useful when the temperature is too variable (e,g, open windows in the winter) or when you want to run the boiler manually. In that case I receive an email on average every 10 minutes (10% chance of an email each time the script runs) to remind me of it and so that I reactivate it eventually.

That's all there is to it. This system has run all winter in our house and it's been warmer and has used less fuel than previous years.

The code could be made more generic to map temperature targets and locations to methods to load their data, and the decision function could be changed to accomodate more location readings. That's not relevant to me at the moment so I haven't done it.


No comments:

Post a Comment