Advanced Multi-Zone Heating Control System for Smart Homes
Components and supplies
![]() |
| × | 1 | |||
| × | 1 | ||||
| × | 1 |
About this project
I builded this device because my kitchen was either to warm or to cold with a single thermostat in my living. Commercial Multi-Zone Heating Controllers (like EvoHome) are very expensive. This program captures the intelligence of these expensive systems, hosted on a simple Arduino Uno board. This fully resolved my issue.
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 Central Heater
- Valves used to open/close zones
- A Watch Dog timer to ensure rock solid operation
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 (otherwise use the manual adjustable knobs on the floor unit)
I explicitly decided not to connect the device to the internet:
- It would increase the risk of mal-functioning (has to be rock solid)
- You can use smart thermostats to control your house. This controller offers nothing extra to adapt remotely
Code
- ProjectCV.ino
- Devices.h
ProjectCV.inoC/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.hC/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();
}
}
};
Schematics
Detailed wiring of periferals (Pump, Valves, Thermostats, LED's)
Example of wiring multiple 'cascaded' controllers. One controller per Floor Unit
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.logexample_fTczkAa0tf.txtInspirational
Manufacturing process
- Solar Heating Systems: From Passive to Active Solutions for Sustainable Home Heating
- Monitoring Your Central Heating Boiler Using a Raspberry Pi – Hardware, Software, and Setup Guide
- DIY Arduino USB Gaming Controller – Build Your Own High-Performance Gamepad
- Smart AV Cabinet Fan Controller – Arduino Nano, DHT11, and Relay for Optimal Cooling
- Arduino DMX-512 Tester Controller – Full Parts Kit for Reliable Lighting Control
- Arduino-Driven GrowBox Controller – Open-Source Firmware & Hardware Guide
- Smart Central Heating Boiler Control Box – Efficient Energy Management
- Arduino‑Powered Smart Irrigation Controller – Auto‑Watering with Weather & Light Sensors
- Comprehensive Pool Controller Kit – Raspberry Pi 2 + Arduino, Relay Boards, Temperature Sensors & Accessories
- Reheating Furnace Steel Heating: Enhancing Hot Rolling Efficiency
