Hardware components | ||||||
![]() |
| × | 1 | |||
| × | 1 | ||||
| × | 1 |
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
Multiple Controllers

/*
* 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);
}
// 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();
}
}
};
Comments
Please log in or sign up to comment.