erkr
Published © GPL3+

Multi-Zone Heating Controller

The smart solution to control multiple zone heating in your house, Controlling the Floor Unit pump will save you over 150 Euro/Year!

BeginnerFull instructions provided23,644
Multi-Zone Heating Controller

Things used in this project

Hardware components

Arduino UNO
Arduino UNO
Use a Mega if you need to controll more then 5 zones (incl the extra zone for the rest of your house without floor heating)
×1
Keyes 8 channel 5Volt relay board
You need a board with 4 relays to control 2 floor zones (1 pump, 1 CV, 2 Valves), One extra relay is needed for every extra floor zone
×1
Honeywell MT8-230-NC Small Linear Thermoelectric Actuator (230v AC)
This is just one of many possible actuators available; You need 1 actuator (valve) per floor Unit group
×1

Story

Read more

Schematics

Schematics

Detailed wiring of periferals (Pump, Valves, Thermostats, LED's)

Multiple Controllers

Example of wiring multiple 'cascaded' controllers. One controller per Floor Unit

Example of the Logging

Some real logging of the Serial Monitor to understand the fuctionallity. The timestamps show e.g. a delay of 5 minutes between opening valves and actually starting the floor unit pump.

The device mounted

Inspirational

Code

ProjectCV.ino

C/C++
/* 
 *  Floor Unit heating controller for multiple Rooms/Zones v1.0
 * 
 *  Copyright: the GNU General Public License version 3 (GPL-3.0) by Eric Kreuwels, 2017
 *  Credits: Peter Kreuwels for defining all use-cases that needed to be considered
 *   
 *  Although this setup already runs for over a full year for my ground floor, I'm not Liable for any error in the code
 *  It can be used as a good basis for your own needs, and should be tested before using
 *   
 *  Highlights/Features:
 *   - You only need to configure the pinning and number of zones 
 *     - A simple Arduino Uno can control up to 5 Floor Unit zones
 *     - With an Arduino Mega the number of Zones is nearly unlimmited
 *   - The provided Program controls: 
 *       - the Floor unit pump
 *       - Aggregates all your zones as just one thermostat to the CV heater
 *       - Valves to open/close zones
 *   - Allows individual Heating per Zone; 
 *     - Per zone a Thermostat to sense the request for heating
 *     - Per zone a Relay to control one or more valves to open/close the Floor unit groups of that zone
 *     - A room with multiple floor unit groups can be considered as one heating Zone (Wire the valves parallel to the Zone Relay)
 *     - This is not only more convenient, but saves energy as well as rooms don't become too warm anymore
 *   - Controls the Floor Unit Pump
 *     - It basically only runs the pump when needed for heating. This already saves you 100-200 Euro electricity per year, 
 *       compared to run the same pump 24/7 (80 Watt is 2kW Hour a day = 0.50 Euro per day)
 *     - Activates the floor unit pump at least once every 36 hours, for 8 minutes if there wasn't any heating request (Summer)
 *     - Prevents to Run the pump without opening the valves first; Taking into account these valves need 3-5 minutes
 *   - Optionally you can control the remainder of your house (rooms without floor heating) as well
 *     - Here you typically will have thermostat knobs on your radiators; so only the rooms that are cold will heat up
 *     - Just add a thermostat in the room(s) you want to control. Wire these thermostats in parallel to the No_Zone input
 *   - Final notes:
 *     - Not all zones need to be controlled; only the zones that become either to warm or stay too cold with 
 *       manual adjusted knobs on the floor unit
 */

#include <avr/wdt.h> // for Watchdog

// WARNING: FAST_MODE is for testing/evaluation/debug purposes (loop runs 50x faster)
// Be carefull using FAST_MODE with a real floor unit pump as it can get damaged with closed valves
// Valves need minimal 3 minutes to open. In FAST_MODE the program doesn't wait long enough before starting the pump

// #define FAST_MODE // 50 times faster execution; consider to disconnect your real CV/Pump!

// In Normal operation to loop runs 10 times per second; so 10 counts/second (600 represents ca 1 minute)
#define VALVE_TIME             3000L    // 5 minutes to open/close a valve (on the safe site; takes typically 3 to 5 minutes)
#ifdef FAST_MODE
#define PUMP_MAINTENANCE_TIME  108000L // For evaluation, activates Floor Unit pump maintenance run once per 4 minutes (time stamp 3 hours)
#else
#define PUMP_MAINTENANCE_TIME  1300000L // Activates Floor Unit pump maintenance run once per 36 hours. Needed to keep pump working
#endif
#define PUMP_ACTIVATION_TIME   5000L    // Activates the pump for ca 8 minutes (10 seconds in test mode)
#define COOLDOWN_TIME          18000L   // When heating is done, continue water circulation for another 30 minutes (40 seconds in test mode)
                                        // This enables further dissipation the heat into the floor (typically takes 15 to 30 minutes)

#include "./Devices.h" // valves, pumps, thermostat classes (use the constants defines above)
struct Zone {
  String name;
  Valve valve;
  Thermostat thermostat;
};

////////////////////////////////////////////////////
//   CONFIGURATION BLOCK                             

// Configure/reorder your pinning as you like (This my wiring on an Arduino Uno); 
// Note: pins 1 and 2 are still free to add an extra zone
#define HEATER_PIN     4  // output to a Relay that is wired with the Thermostat input of your heating system
#define FU_PUMP_PIN    5  // output to a Relay that switches the Floor Unit Pump
#define LIVING_VALVE   7  // Zone 1: output to a Relay that controls the Valve(s)
#define KITCHEN_VALVE  6  // Zone 2: output to a Relay that controls the Valve(s)
#define DINING_VALVE   3  // Zone 3: output to a Relay that controls the Valve(s)

#define LIVING_THERMO  8  // Zone 1; input wired to the thermostat in the living
#define KITCHEN_THERMO 9  // Zone 2; input wired to the thermostat in the kitchen
#define DINING_THERMO  11 // Zone 3; input wired to the thermostat in the dining
#define NO_ZONE_THERMO 10 // Optionally: thermostats in rooms without floor heating

#define HEATING_LED    12 // On when heating, Alternates during cooldown, is Off in idle mode
#define INDICATION_LED 13 // Alternates the on board LED to indicate board runs; can be easily removed to free an extra IO pin!!

// Configure the Floor Unit Zones/rooms. Each zone/room owns a name, valve and thermostat:
#define NR_ZONES 3
Zone Zones[NR_ZONES] = { {"Living Room",  Valve(LIVING_VALVE, "Living Valve"),  Thermostat(LIVING_THERMO, "Living Thermostat")},
                         {"Kitchen Area", Valve(KITCHEN_VALVE,"Kitchen Valve"), Thermostat(KITCHEN_THERMO,"Kitchen Thermostat")},
                         {"Dining Room",  Valve(DINING_VALVE, "Dining Valve"),  Thermostat(DINING_THERMO, "Dining Thermostat")}};

// END CONFIGURATION BLOCK                                           
//////////////////////////////////////////////////


// Some fixed devices:
LED           iLED(INDICATION_LED, "Indicator LED"); // can be removed if you run out of IO's
LED           hLED(HEATING_LED, "Heating LED");
Manipulator   CV(HEATER_PIN, "CV Heater");
Pump          FUPump(FU_PUMP_PIN, "Floor Unit Pump");
Thermostat    ZonelessThermo(NO_ZONE_THERMO, "Zoneless Thermostat"); // For the rest of the house, no related to the floor unit zone

void printConfiguration() {
   Serial.println("------ Board Configuration: ---------");
   iLED.Print();
   hLED.Print();
   CV.Print();
   FUPump.Print();
   ZonelessThermo.Print();
   for(int i=0; i<NR_ZONES; i++) {
      Serial.print("Zone["); Serial.print(i+1); 
      Serial.print("]: "); Serial.println(Zones[i].name);
      Serial.print(" - "); Zones[i].valve.Print();
      Serial.print(" - "); Zones[i].thermostat.Print();
   }
   Serial.println("-------------------------------------");
}

// state machine, with both transition and state handling actions
class State
{
  public:
  enum states {
    idle,
    on,
    cooldown
  };

  private:
  //vars
  states _State;
  unsigned long cooldownCount;

  public: 
  //constructor
  StateMAchine()   {
    _State = idle;
  }
  
  // Getter
  states const& operator()() const {
      return _State;    
  }

  // Setter
  void operator()(states const& newState) {
      printTimeStamp();
      Serial.print(": State change from [");
      Print();
      _State = newState;
      Serial.print("] to [");
      Print();
      Serial.println("]");
      // deal with transition actions to the new state
      switch(_State)
      {
       case idle:
          hLED.Off();
          CV.Off();  // stop heating
          FUPump.Off(); 
          allValvesOff();
          break;
       case on:
          hLED.On();
          CV.On(); // start heating, but Floor unit pump has to wait till at least one zone is open
          break;
       case cooldown:
          CV.Off();  // stop heating
          allValvesOn(); // open all zones for cooldown; pump has to wait for this
          break;  
       default: 
         Serial.println("WARNING Unhandled State transition");
         break;
      }
  }

  // Do the state handling; to be called repatively by the loop()  
  void doProcessingActions() {
    switch(_State) {
       case on:
        onProcessing(); // As long heating is requested open/close zones matching the heating requests
        break;    
      case cooldown:
        coolDownProcessing(); // take 30 minutes to dissipate remaing Heat into the floor
        break;
      case idle:
        idleProcessing(); // operate pumps/valves once per day
        break;
      default: 
        Serial.println("ERROR Unhandled State");
        break;
    }
  }

  void setCoolDownNeeded() {
    cooldownCount = COOLDOWN_TIME;
  }
  bool whileCoolDownNeeded() { // down counts the time
     if (cooldownCount > 0) {
        cooldownCount--;
     }
     return checkCoolDownNeeded();
  }
  bool checkCoolDownNeeded() {
     return (cooldownCount> 0);
  }
  
  void Print()   {
    switch(_State)
    {
     case idle:
        Serial.print("idle");
        break;
     case on:
        Serial.print("on");
        break;
     case cooldown:
         Serial.print("cooldown");
        break;  
    }
  }
};

// The global state machine
State    CVState;


void setup() 
{
  // initializations
  Serial.begin(115200);
  printTimeStamp();
  Serial.print(": ");
#ifdef FAST_MODE
  Serial.println("CV Zone Controller started in TestMode!\n"
                 " - Board time runs ca 50 times faster\n"
                 " - Pump maintenance cycle runs ever 3 hours instead once per 36 hours");
#else
  Serial.println("CV Zone Controller started. Time stamps (dd:hh:mm:ss)");
#endif
  Serial.println(" - Time stamps format (dd:hh:mm:ss)");
  printConfiguration();
  wdt_enable(WDTO_1S);  // Watchdog: reset board after one second, if no "pat the dog" received
}

void loop() 
{

#ifdef FAST_MODE
  delay(2); // 50 times faster so minutes become roughly seconds for debugging purpose; so every count for cooldown or idle is 0.002 second
#else
  delay(100); // Normal operation: loops approx 10 times per second; so every count for cooldown or idle is 0.1 second
#endif

  // Use Indication LED to show board is alive
  iLED.Alternate();

  // once per loop() the pump and valves need to opdate hteir administatrion
  FUPump.Update();
  for (int i=0; i<NR_ZONES; i++) {
    // Update valve administration for transition times to open/close
    Zones[i].valve.Update();
  }
  
  // Reset the WatchDog timer (pat the dog)
  wdt_reset();  

  // Do the state handling
  CVState.doProcessingActions();
}

////////////////////////////////////////////////////////////////////
// Processing Methods for each CV State
/////////////////////////////////////////

void  onProcessing() {
  if (ProcessThermostats())  { // returns true if at least one of the thermostats is on (switch closed) => stay in this state
    if (FloorPumpingAllowed())   {
       FUPump.On();
    }
    else {
       FUPump.Off(); 
    }
  }
  else if ( CVState.checkCoolDownNeeded() ) {  // Continue in cooldown state to keep pump running for a while
      CVState(State::cooldown); 
  }
  else  {  // skip cooldown for floor unit, go back to idle
      CVState(State::idle); 
  }
}


void coolDownProcessing() {
  hLED.Alternate();
  if (HeatingRequested()) {   // returns true when one of the thermostats is closed
    CVState(State::on);
  }
  else  {
    if ( CVState.whileCoolDownNeeded() ) {
      if (FloorPumpingAllowed()) {
         FUPump.On();
      } else {
         FUPump.Off();  
      }        
    }
    else {
      CVState(State::idle);
    }
  }
}

void idleProcessing() 
{
  if (HeatingRequested())  {    // returns true when one of the thermostats is closed
    CVState(State::on);
  }
  else
  {
    // During idle period this check will activate the Floor Unit Pump for 8 minutes per 36 hours to keep them operatable
    if ( FUPump.doMaintenanceRun()) {
      if (FUPump.IsOff()) {
        if ( allValvesOpen() == false ) { // start opening just once
          printTimeStamp(); Serial.println(": Start daily cycle for Floor Unit Pump; open valves: ");
          allValvesOn();
        }
        if (FloorPumpingAllowed()) { 
          // this takes ca 5 minutes after activating the valves (6 seconds in test mode)
          printTimeStamp(); Serial.println(": Start daily cycle for Floor Unit Pump; start pump ");
          FUPump.On(); 
        }
        
      }
    } 
    else if (FUPump.IsOn()) {  // no Maintenance needed. So stop pump if still running
        printTimeStamp(); Serial.println(": Stop daily cycle for Floor Unit Pump; stop pump and close valves");
        FUPump.Off();
        allValvesOff();
    }
  }
}

////////////////////////////////////////////////////////////////////
// Helper Methods used by the State handlers
/////////////////////////////////////////

void allValvesOff() {
  for (int i=0; i<NR_ZONES; i++) {
    Zones[i].valve.Off();
  }    
}

void allValvesOn() {
  for (int i=0; i<NR_ZONES; i++) {
    Zones[i].valve.On();
  }    
}

bool allValvesOpen() {
  for (int i=0; i<NR_ZONES; i++) {
    if ( Zones[i].valve.IsOff() ) {
       return false;
    }
  }
  return true;
}


bool FloorPumpingAllowed() 
{
  // returns true if at least one zone is open, taking Valve transition into account
  for (int i=0; i<NR_ZONES; i++) {
    if (Zones[i].valve.ValveIsOpen() ) {
      return true;
    }
  }
  return false; // all valves are closed
}

bool ProcessThermostats() // returns true when one of the thermostats is closed
{                         // (De-)Activates Floor zones
   bool heating=false;
   bool requested[NR_ZONES]; 
    
   if ( ZonelessThermo.IsOn() ) {
     heating = true;
   }
   for (int i=0; i<NR_ZONES; i++) {
     // record heating requests only once to avoid race conditions due changes in between
     requested[i] = Zones[i].thermostat.IsOn();
     if ( requested[i] ) {
       heating = true;
       CVState.setCoolDownNeeded(); // remember if there was a request for heating a floor unit zone
     }
   }
   for (int i=0; i<NR_ZONES; i++) {
     if ( requested[i] ) {
       // Selectively open valves for zones that request heating 
       Zones[i].valve.On();
     }
     else if (heating) {
       // Selectively close valves for zones that don't require heating anymore
       // Only close them if heating is still required because in cooldown all zones need to be open
       Zones[i].valve.Off();
     }
   }
   return heating;
}

bool HeatingRequested()
{
  if ( ZonelessThermo.IsOn() ) {
    return true;
  }
  for (int i=0; i<NR_ZONES; i++) {
    if (Zones[i].thermostat.IsOn() ) {
      return true;
    }
  }
  return false; // all Thermostats are open (no heating needed)
}

void printTimeStamp() {
  #ifdef FAST_MODE
    // 50 times faster; represent the time it would be in normal mode
     unsigned long seconds = millis()/(unsigned long)20;
  #else
    // Normal operation
     unsigned long seconds = millis()/(unsigned long)1000;
  #endif
    unsigned long minutes, hours, days;
    minutes = seconds / 60L;
    seconds %= 60L; 
    hours = minutes / 60L;
    minutes %= 60L;
    days = hours / 24L;
    hours %= 24L;
    char time[30]; 
    sprintf(time, "%02d:%02d:%02d:%02d", (int)days, (int)hours, (int)minutes, (int)seconds);
    Serial.print(time);
}

Devices.h

C/C++
// Helper classes for IO devices
extern void printTimeStamp(); // defined in main ino file

// IODevice: base class for all IO devices; needs specialization
class IODevice { 
  //vars
  protected:
  bool _IsOn;
  int _Pin;
  String _Name;
  
  //constructor
  public:
  IODevice(int pin, String name)   {
    _IsOn = false;
    _Pin = pin;
    _Name= name;
  }
  //methods
  virtual bool IsOn() = 0; // abstract
  virtual bool IsOff() {   // default for all
    return !IsOn();
  }

  void DebugPrint()   {
    printTimeStamp();
    Serial.print(": ");
    Print();
  }
  void Print() {
    Serial.print(_Name);
    Serial.print(" on pin(");
    Serial.print(_Pin);
    if (_IsOn)
      Serial.println(") = On");
    else
      Serial.println(") = Off");
  }
};

// Thermostat: reads an digital input adding some dender surpression 
class Thermostat : public IODevice  
{
  //vars
  private:
  int _Counter; // used to prevent reading intermitted switching (dender)
  
  //constructor
  public:
  Thermostat(int pin, String name) : IODevice(pin, name)   {
    _Counter = 0;
    pinMode(_Pin, INPUT_PULLUP); 
  }

  //methods  
  virtual bool IsOn()   {
    if (digitalRead(_Pin) == HIGH  && _IsOn == true) // open contact while on
    {
      if( _Counter++ > 5) // only act after  5 times the same read out
      {
         _IsOn = false;
         DebugPrint();
         _Counter = 0;
      }
    }
    else if (digitalRead(_Pin) == LOW  && _IsOn == false) // closed contact while off
    {
      if( _Counter++ > 5) // only act after  5 times the same read out
      {
         _IsOn = true;
         DebugPrint();
         _Counter = 0;
      }
    }
    else 
    {
       _Counter = 0;
    }
    return _IsOn;
  }
};

// Manipulator: the most basic working device on an digital output  
class Manipulator : public IODevice
{
  //vars
  private:

  //constructor
  public:
  Manipulator(int pin, String name)  : IODevice(pin, name)   {
    pinMode(_Pin, OUTPUT);   
    digitalWrite(_Pin, HIGH);
  }
  //methods
  void On()    {
    if (_IsOn == false)
    {
      _IsOn = true;
      digitalWrite(_Pin, LOW);
      onSwitch();
    }
  }

  void Off()   {
    if (_IsOn == true)
    {
      _IsOn = false;
      digitalWrite(_Pin, HIGH);
      onSwitch();
    }
  }

  virtual void onSwitch() {  // trigger for child claases; change in on/off state
     DebugPrint();  
  }

  virtual bool IsOn()   {
    return _IsOn;
  }
};

// Valve: controlles themostatic valves on a digital output. 
// These valves react slowly (3-5 minutes) so this class adds this transition awareness
// loop() must call Update() to keep track if the valve is fully open or closed
class Valve : public Manipulator
{
  private:
  long transitionCount;

    //constructor
  public:
  Valve(int pin, String name) : Manipulator(pin, name)   {
    transitionCount = 0;
  }

  bool ValveIsOpen()   {
    return (IsOn() && (transitionCount>=VALVE_TIME)); // at least 5 minutes in on state
  }
  
  // Execute once per pass in the sketch loop() !!!
  void Update()   { 
    if (IsOn())     {
      if (transitionCount < VALVE_TIME)
        transitionCount++;
    }
    else     {
      if (transitionCount > 0)
        transitionCount--;
    }
  }
};


// Pump: a pump need to be activated several times a week to keep them going. 
// loop() must call Update() to keep track when a maintenance activation is needed
class Pump : public Manipulator
{
  // valves react slowly (3-5 minutes) so this class adds this transition awareness
  private:
  long counter;
  bool doMaintenance;

    //constructor
  public:
  Pump(int pin, String name) : Manipulator(pin, name)   {
    counter = 0;
    doMaintenance = false;
  }

  bool doMaintenanceRun()   {
    return doMaintenance;
  }

  virtual void onSwitch() {  // change in on/off state
     Manipulator::onSwitch();  
     counter = 0;
  }

  // run this method every pass in loop() 
  void Update()   {
    if (IsOn()) {
      if (counter < PUMP_ACTIVATION_TIME) {
        counter++;
      } else if (doMaintenance) {
        printTimeStamp();
        Serial.println(": Pump Maintenance cleared");
        doMaintenance = false;
      }
    }
    else {
      if (counter < PUMP_MAINTENANCE_TIME) {
        counter++;
      }  else if (doMaintenance==false) {
        printTimeStamp();
        Serial.println(": Pump Maintenance needed");
        doMaintenance = true;
      }
    }
  }
};

// LED; besides on/off it offers a method to alternate the LED (1Hz)
// just call Alternate() from the loop() to activate alternation
class LED : public Manipulator
{
  private:
  long counter;

    //constructor
  public:
  LED(int pin, String name) : Manipulator(pin, name)   {
    counter = 0;
  }

  virtual void onSwitch() {  // change in on/off state
    // surpress printing debug output for LEDs
  }

  void Alternate() {
  #ifdef FAST_MODE
    if (counter++ > 250)
  #else
    if (counter++ > 5)
  #endif  
    {  // toggle LED 
      counter=0;
      if (IsOn())
        Off();
      else
        On();
    }
  }
};

Credits

erkr

erkr

4 projects • 19 followers
Thanks to Peter Kreuwels.

Comments