Thursday, April 4, 2019

Thermostat and Node Red

I don't know if I will cover all of this in one post or two. This is the biggest and most complex node I have done. Since it is built on built on public documents and similar ideas, it may go quick.

The reason I built this, we have a nice gas furnace in our sun-room. It is a good sun-room, with lots of windows. Windows are not known for holding in heat. I think this was an add on to our house (looking at the rest of the house and how it was framed underneath it seemed to be maybe a 3 season porch that someone insulted). Imagine this room with the sun shining and leaves on the trees!



The heater had a remote control that was mounted on the wall, and could be used as a thermostat. The bad thing is, the heater gets hot, and sometimes the remote didn't get put back into the little hook on the wall, and well when the remote is sitting on the heater, the case deforms.



I was eventually able to get the cover off the battery compartment, but it never quite worked after that. On eBay these controls sell for over $100, which seems extreme.  For about two years, we just manually controlled the heater with the manual override switch. Sometimes the switch would be left on for days, where other times it was really cold in the room, and it took an hour or more to get up to a comfortable temperature.

We needed a thermostat for that room. Keeping the temperature at some minimum would make the room livable all the time. Preventing the heater from staying on for days would save money. Initially I was going to build a standalone thermostat that would be connected via wires to the heater. Since the thermostat should be away from the heater, wires meant drilling in the walls, and fishing, and lotsa work. I thought about two nodeMCUs that would communicate over WiFi, and that lead me into node-red.

This was actually my first set of node-red nodes. I have a nodeMCU that controls a relay behind the gas stove in a 3D printed box. The Thermostat is controlled by another nodeMCU connected to a touch screen, and a temp/humidity sensor (DHT-22). It has a USB powersupply powering it. The case I am using now is something I found on thingiverse.

I used point to point wiring and followed some basic instructions I found. The touchscreen input and the display are separate devices, but both talk SPI so can be wired to the same SPI pins on the nodeMCU.



Adafruit has a graphics library for the ILI9341 display that includes several fonts. The touchscreen uses the XP2046 chip, so you will need a library for that as well. I think in the Arduino library manager, entering XP2046 will allow installing the library directly without any compiles or other problems. There are displays available in 2.4" and 2.8" (and probably other sizes) that all use the same libraries. The case will need to change to make it all work.

Once the libraries are all installed, then the fun starts. Doing any GUI, we need to provide proper information, as well as simple understandable controls. It is like a joke, if you have to explain it, it isn't that good. In the above display, the top box contains the current Room information. The bottom is the current heater settings with two arrows, one up to raise the temperature, and one down to lower the temperature.

The top of the display is the current date and time. I promise soon there will be an explanation of the Date/Time flow, but this is already getting long, so I probably won't explain it today.

The display can be as fancy as you want it to be. It could have 3d animations for the buttons and fading between temp and humidity, an analog clock or whatever you want to program. I started out pretty basic, and find it meets the needs of the family.

The code, well, it is a little messy, because it is doing a lot!

/**
 * Using the DHT 22 Sensor, get the current temprature and humidity. 
 * Send these values out on the MQTT topics:
 *    sunroom/temperature
 *    sunroom/humidity
 *    
 * Using the touchscreen, display the current time, temprature, humidity, 
 * and thermostat settings. The touch screen allows changing the thermostat
 * setting and sending the value on the MQTT topic:
 *     sunroom/thermostat
 * The thermostat can be changed by listening to the MQTT topic
 *     heater/thermostat
 * The on board LED is controlled by the MQTT topic
 *     heater/light
 * The time is received on the MQTT topic
 *     time/ISO-8601
 *     
 * The code is copyright 2018, Tom Brusehaver 
 * It can be copied using the OSF v3 license. Do what you want with this, but share.
 * 
 * The touchscreen connections are on:   https://nailbuster.com/?page_id=341
 * 
 * Note the input (touch) is the XPT2046, the graphics are the ILI9341
 * Many of the adafruit modules use a different touch input (most of the 
 * ILI9341 touch screen input examples don't work). 
 *
 * The board selected in the IDE is WeMos D1 R2 & Mini. Standard everything 
 * 
 */

#include <SimpleDHT.h>

#include <ESP8266WiFi.h>
#include <PubSubClient.h>
#include <PubSubClientTools.h>
#include <ArduinoJson.h>

#include <XPT2046_Touchscreen.h>
#include <SPI.h>
#include "Adafruit_GFX.h"
#include <Fonts/FreeSans9pt7b.h>
#include <Fonts/FreeSans18pt7b.h>
#include <Fonts/FreeSans12pt7b.h>
#include "Adafruit_ILI9341.h"

const char* ssid = "raspi-webgui";
const char* password = "XXXXXXXXi";
const char* MQTT_SERVER = "10.3.141.1";

String state="off";

// For the Adafruit shield, these are the default.
#define TFT_DC 2
#define TFT_CS 15

#define TCS_PIN  4
// MOSI=11, MISO=12, SCK=13

#define DHTTYPE DHT12   // DHT 22

// DHT Sensor
const int DHTpin = 16;
SimpleDHT22 dht22(DHTpin);

//XPT2046_Touchscreen ts(CS_PIN);
#define TIRQ_PIN  5
//XPT2046_Touchscreen ts(CS_PIN);  // Param 2 - NULL - No interrupts
//XPT2046_Touchscreen ts(CS_PIN, 255);  // Param 2 - 255 - No interrupts
XPT2046_Touchscreen ts(TCS_PIN, TIRQ_PIN);  // Param 2 - Touch IRQ Pin - interrupt enabled polling

// Use hardware SPI 
Adafruit_ILI9341 tft = Adafruit_ILI9341(TFT_CS, TFT_DC);


WiFiClient espClient;
PubSubClient client(MQTT_SERVER, 1883, espClient);
PubSubClientTools mqtt(client);

/**
ThreadController threadControl = ThreadController();
Thread thread = Thread();
**/

/** Global Values **/
long lastMsg = 0;
String s = "";
int    thermostatSetting=70;
String currTime="2018-01-01T00:00";
String eraseTime="2018-01-01T00:00";
String currTemp="0";
String eraseTemp="0";
String currHumid="0";
String eraseHumid="0";
String currThermo="70";
String eraseThermo="70";
String publishedThermo="70";
int outsideTemperature=70;
String currOutsideTemp="70";

const int width=240;
const int height=320;

void setup() {
  Serial.begin(115200);

  // graphics start
  tft.begin();
  // touch start
  ts.begin();

  // read diagnostics (optional but can help debug problems)
  uint8_t x = tft.readcommand8(ILI9341_RDMODE);
  Serial.print("Display Power Mode: 0x"); Serial.println(x, HEX);
  x = tft.readcommand8(ILI9341_RDMADCTL);
  Serial.print("MADCTL Mode: 0x"); Serial.println(x, HEX);
  x = tft.readcommand8(ILI9341_RDPIXFMT);
  Serial.print("Pixel Format: 0x"); Serial.println(x, HEX);
  x = tft.readcommand8(ILI9341_RDIMGFMT);
  Serial.print("Image Format: 0x"); Serial.println(x, HEX);
  x = tft.readcommand8(ILI9341_RDSELFDIAG);
  Serial.print("Self Diagnostic: 0x"); Serial.println(x, HEX); 

  tft.fillScreen(ILI9341_BLUE);
  displayThermoButtons();

  // Connect to WiFi
  Serial.print(s+"Connecting to WiFi: "+ssid+" ");
  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println(s+" connected with IP: "+WiFi.localIP());

  // Connect to MQTT
  Serial.print(s+"Connecting to MQTT: "+MQTT_SERVER+" ... ");
  if (client.connect("SRThermostat")) {
    Serial.println("connected");

    mqtt.subscribe("heater/light", topic1_subscriber);
    mqtt.subscribe("heater/thermostat", topic2_subscriber);
    mqtt.subscribe("time/ISO-8601", topic3_subscriber);
    mqtt.subscribe("outside/farenheight", topic4_subscriber);
  } else {
    Serial.println(s+"failed, rc="+client.state());
  }
}

void reconnect() {
  // Loop until we're reconnected
  while (!client.connected()) {
    Serial.print("Attempting MQTT connection...");
    // Attempt to connect
    if (client.connect("SRThermostat")) {
      Serial.println("connected");
      // Once connected, publish an announcement...
      client.publish("outTopic","thermostat Restart");
      mqtt.publish("sunroom/temperature", currTemp);
      mqtt.publish("sunroom/thermostat", currThermo);
      mqtt.subscribe("heater/light", topic1_subscriber);
      mqtt.subscribe("heater/thermostat", topic2_subscriber);
      mqtt.subscribe("time/ISO-8601", topic3_subscriber);
    } else {
      Serial.print("failed, rc=");
      Serial.print(client.state());
      // Wait 5 seconds before retrying
    }
  }
}

void loop() {
  if (ts.touched()) {
    TS_Point p = ts.getPoint();
    Serial.print("Pressure = ");
    Serial.print(p.z);
    Serial.print(", x = ");
    Serial.print(p.x);
    Serial.print(", y = ");
    Serial.print(p.y);
    Serial.println();

    if ((p.y > 3000) && (p.y < 3800))
    {
      Serial.println("** Y zone **");
      if ((p.x > 1100) && (p.x < 1600)) // UP button
      {
        currThermo = String(++thermostatSetting);
      }
      if ((p.x > 500) && (p.x < 1000)) // Down button
      {
        currThermo = String(--thermostatSetting);
      }
    }
  }

  if (!client.connected()) {
    reconnect();
  }

  client.loop();
  
  publisher();           // send out current values.
  tft.setFont();
  displayTime(2);
  tft.setFont(&FreeSans18pt7b);
  displayTemp(2);
  tft.setFont(&FreeSans9pt7b);
  displayHumid(1);
  tft.setFont(&FreeSans9pt7b);
  displayThermostat(2);

  tft.drawRect(25, 40, 200, 125, ILI9341_LIGHTGREY);
  tft.setTextColor(ILI9341_LIGHTGREY);
  tft.setTextSize(1);
  tft.setCursor(28, 55);
  tft.println("Room");

  tft.drawRect(25, 170, 200, 125, ILI9341_LIGHTGREY);
  tft.setTextColor(ILI9341_LIGHTGREY);
  tft.setTextSize(1);
  tft.setCursor(28, 195);
  tft.println("Thermostat");
}

void displayTime(int fontSize)
{
  if (!currTime.equals(eraseTime))
  {
    tft.setCursor(1, 1);
    tft.fillRect(1, 1, width, fontSize*10, ILI9341_BLACK);
    tft.setTextColor(ILI9341_BLACK);
    tft.setTextSize(fontSize);
    tft.println(eraseTime);
  }
  tft.setCursor(1, 1);
  tft.setTextColor(ILI9341_WHITE);
  tft.setTextSize(fontSize);
  tft.println(currTime);
  eraseTime = currTime;
}

void displayTemp(int fontSize)
{
  int X=60; 
  int Y=150;
  int intTemp = currTemp.toInt();
  if (!currTemp.equals(eraseTemp))
  {
    tft.fillRect(X, Y-90, 150, 100, ILI9341_BLUE);
  }
  tft.setCursor(X, Y);
  tft.setTextColor(ILI9341_GREEN);
  tft.setTextSize(fontSize);
  tft.println(intTemp);
  eraseTemp = currTemp;
}

void displayHumid(int fontSize)
{
  int X=50; 
  int Y=80;
  if (!currHumid.equals(eraseHumid))
  {
    tft.fillRect(X, Y-22, 120, 25, ILI9341_BLUE);
  }
  tft.setCursor(X, Y);
  tft.setTextColor(ILI9341_GREEN);
  tft.setTextSize(fontSize);
  tft.print("Humid: ");
  tft.print(currHumid);
  tft.println("%");
  eraseHumid = currHumid;
}

void displayThermostat(int fontSize)
{
  int X=35; 
  int Y=240;
  if (!currThermo.equals(eraseThermo))
  {
    tft.fillRect(0, Y-30, width, 35, ILI9341_BLUE);
    displayThermoButtons();
  }
  tft.setCursor(X, Y);
  tft.setTextColor(ILI9341_GREEN);
  tft.setTextSize(1);
  tft.print("Heat at: ");
  tft.setTextSize(fontSize);
  tft.print(currThermo);
  eraseThermo = currThermo;
}

void displayThermoButtons()
{
   int X = width-75;
   int Y = 180;

   tft.fillRect(X,Y, 50, 50, ILI9341_BLACK);
   tft.fillTriangle(X+5,Y+40, X+25,Y+5, X+45,Y+40, ILI9341_BLUE); 
   Y+=60;
   tft.fillRect(X, Y, 50, 50, ILI9341_BLACK);
   tft.fillTriangle(X+5,Y+10, X+25,Y+45, X+45,Y+10, ILI9341_BLUE);
}

float degCtoF(float deg)
{
  return (deg * 9.0 / 5.0) + 32.0;
}

void publisher() {
  long now = millis();
  if (now - lastMsg > 20000) 
  {
    lastMsg = now;
    Serial.print("Publish message: ");
    float humid = 0;
    float tempC = 0;
    if (dht22.read2(&tempC, &humid, NULL) == 0)
    {
      float tempFl = degCtoF(tempC);
      String val = String(tempFl);
      mqtt.publish("sunroom/temperature", val);
      currTemp=String(tempFl);
      Serial.print("Temp: ");  Serial.println(val);
      val = String(humid);
      mqtt.publish("sunroom/humidity", val);
      currHumid = val;
    }
    else
    {
      Serial.println("Error Reading DHT device");
    }
  }
  if (!currThermo.equals(publishedThermo))
  {
    mqtt.publish("sunroom/thermostat", currThermo);
    publishedThermo = currThermo;
  }
}

void topic1_subscriber(String topic, String message) {
  Serial.println(s+"Message arrived in function 1 ["+topic+"] "+message);
  if (message.equals("on"))
  {
    digitalWrite(BUILTIN_LED, LOW);   // LED on 
    state = "on";
  }
  else
  {
    digitalWrite(BUILTIN_LED, HIGH);
    state="off";
  }
}

/**
 * Read number value of thermostat setting.
 */
void topic2_subscriber(String topic, String message) {
  Serial.println(s+"Message arrived in function 2 ["+topic+"] "+message);
  int thermSetting = message.toInt();
  if ((thermSetting > 55) && (thermSetting < 99))
  {
    thermostatSetting = thermSetting;
    currThermo = String(thermostatSetting);
  }
}

/**
 * Read current time.  (ISO-8601 format: 2016-06-01T21:34:12.629Z)
 */
void topic3_subscriber(String topic, String message) {
  StaticJsonDocument<300> doc;

  DeserializationError error = deserializeJson(doc, message);
  if (error) {
    Serial.println("Parsing failed");
    return;
  }
   // Get the root object in the document
  JsonObject root = doc.as<JsonObject>();

  currTime = root["CDTstring"].as<String>();
  Serial.println(s+"Message arrived in function 3 ["+topic+"] "+currTime);
}

/**
 * Read outside temperature.
 */
void topic4_subscriber(String topic, String message) {
  int temp = message.toInt();
  outsideTemperature = temp;
  currOutsideTemp = String(outsideTemperature);
  
  Serial.println(s+"Message arrived in function 4 ["+topic+"] "+outsideTemperature);
}

The code starts as most of other NodeMCU nodes I have shown. The WiFi SSID and password are there, along with the MQTT broker address. Several globals are defined next. Since the screen tries to update as fast as possible, I have "erase" values. These are the previous settings that the screen will draw in the background color to speed up changes to that section of the screen. I full refresh of the screen over SPI is visible, and should be avoided if possible.

In the setup function, I initialize the ILI9341 display, and read out the current settings. Then I fill the whole screen with BLUE. This is a very saturated blue, and I have thought about changing it to something more muted. In a previous post, I talk about GUIs and muted colors. I then draw the thermostat buttons. This is in another function, and allows me to play with different looking buttons. I've found for this, the basic buttons (triangles inside of squares) are nice enough.

The WiFi and MQTT are started next. I subscribe to 4 topics:
  • heater/light   - The basic on board LED state (on/off) for testing.
  • heater/thermostat - The setting that node-red wants the thermostat at
  • time/ISO-8601 - The time to display
  • outside/farenheight  - The outside temperature (not displayed).
The reconnect publishes 2 messages, if the system ever disconnects from the MQTT broker, current temperature (sunroom/temperature topic) and the current thermostat setting (sunroom/thermostat topic). The thermostat can not only set the temperature to keep the room at, it also listens to Node-Red to know what it wants the thermostat to be set at. This adds complexity to Node-Red, this node is pretty simple, just displaying the last input to the thermostat it got. 

The first thing the loop function checks is if anything is touching the screen. These touch screens are resistive, so they take some pressure to register a touch. For this app, the screens are fine, since the touched area will be quite large. The touch location is something you have to work out depending on the orientation of the screen. The screen touch is roughly positions 0 to 4000 right to left (y direction) and 0-1800 top to bottom (x direction). For my display with the up/down arrows in the lower left, the arrows are 3000 on the left, and 3800 on the right. The up arrow touch area starts at 1100 bottom and goes to 1600 top. The down arrow starts at 500 bottom and goes to 1000 on top. This I derived trial and error with the help of the serial output. 

The loop then calls the publisher to read the current temperature and humidity, and publish these values. Unlike other nodes, I convert the temperature C to temperature F before publishing. Someday I may switch this, but for now, it is done here. The screen is updated after that. All the sections of the screen have their own function to update the display. Using the separate functions should allow a more modular display. I can move the sections by changing the X and Y values in the individual functions. The loop function also has some grey rectangles seperating the sections making the screen a little more finished looking.
When messages come in on the subscribed topics, the values are updated only. The display will update using these values in the next loop() function. There are some safety checks (thermometer cannot be set below 55 or greater than 99) where it is a good idea. 

The time comes in a full json message that needs to be parsed out. For now I have it read only the CDTstring that is encoded in the message. The message is separated out into hours, minutes, seconds and month, day, year. I only send the Central time version, since I can think in local time. 

This node does not turn on the heater anywhere, nor does it compare the thermostat setting to the current temperature. This node only maintains the current settings. Node-Red will turn on the heater if needed, and check the current thermostat value comparing to current temperature. The flow will be handled next time. 

Come back next time.






2 comments:

  1. This article is nicely written. A very well written guide to take care of your furnace.
    www.cosmopolitanmechanical.ca

    ReplyDelete
  2. VERY INFORMATIVE ARTICLE. Thanks
    www.cosmopolitanmechanical.ca

    ReplyDelete