A thermostat is really nothing more than a switch that triggers appropriate actions at certain temperatures. The thermostat should not have to worry about what kind of sensor is transmitting the temperature to it. Sensors such as the BME280/680, SHT31, DHT11/22 or even a simple NTC resistor measure different values. Apart from the temperature, it can also be relative humidity, air pressure and air quality or a calculated value such as the dew point.
A thermostat must therefore only be able to query the temperature from the sensor and take action if the temperature is too low or too high. Because the actions can be different depending on the application, we declare them as callback functions, which the application program must implement.
The thermostat gains access to the sensor via a reference to a generic interface that is realized in C++ as a pure abstract class. This class defines the virtual methods that must be implemented by all sensor types.
class ISensor
{
public:
virtual void setup() = 0; // initialize the sensor
virtual void readSensor() = 0; // read the sensor values into the sonsor data struct
virtual float getCelsius() = 0; // returns the temperature in °C
virtual void printData() = 0; // print the data read from the sensor data struct
virtual SensorData& getDataReference() = 0; // get a reference to the sensor data struct
};
So we declare a Thermostat class, which essentially looks like this:
using Callback = void (&)();
class Thermostat
{
public:
Thermostat(Isensor& sensor, Callback processData),
Callback onLowTemp, Callback onHighTemp :
_sensor(sensor), _processData(processData),
_onLowTemp(onLowTemp), _onHighTemp(onHighTemp)
{}
private:
Isensor& _sensor;
Callback _processData;
Callback _onLowTemp;
Callback _onHighTemp;
Maybe you also have some of these strange components in your tinkering box, which you removed from a defective battery pack or an old PC power supply. These are NTC resistors, i.e. resistors with a negative temperature coefficient. Their resistance therefore decreases with increasing temperature. They are suitable for temperature measurement or, as the title of this project says, for building a thermostat.
If we connect an NTC resistor in series with another resistor whose value we know, we can measure the voltage at this voltage divider and calculate the unknown resistance value of the NTC from it. We can set up the voltage divider in two different ways, namely connect the NTC to ground or to Vcc.
---+-- Vcc ---+--- Vcc
| |
.-. .-.
| | Rs |\| NTC
'-' '-'
| |
+---> analog input +---> analog input
| |
.-. .-.
|/| NTC | | Rs
'-' '-'
| |
---+--- GND ---+--- GND
If we assume that the ADC (analog to digital converter) of our microcontroller delivers the value Aval between 0 and Amax (e.g. 0..1023), for input voltages from 0V to Vcc , the resistance of the NTC for the two cases is calculated as follows:
NTC to GND | NTC to Vcc | |
---|---|---|
Now we know the resistance value of the NTC as a function of the measured voltage. But how do we calculate the temperature from this? The Beta-Formula helps us ( see NTC ) :
In the Beta-Formula
Attention! In the formulas, the temperatures must be given in °Kelvin
The ADC of the Arduino Uno maps the full input range 0..5V to the range 0..1023
The ADC of the ESP8266 has an input range of 0..1V which is mapped to 0..1023. But the Wemos D1 has an internal voltage divider 220k/100k with midpoint connected to the ADC and the 220k-endpoint connected to pin A0. Therefore the input voltage to convert at A0 must not exceed 3.2V to get the full range.
The ADC of the ESP32 has selectable attenuations for different reference voltages. It maps the input voltage range from 0 to Vref to the range of 0..4095. Unfortunately the ADC also has a certain offset voltage which we have to consider ( see ADC ESP32 ) :
Attenuation ADC_0db ADC_2_5db ADC_6db ADC_11db (default)
Vref 1100mv 1300mv 1800mv 3200mv
Voff 65mv 65mv 90mv 130mv
We can express the relationship between Vin and the analog output value Aval, taking Voff into account, with the following linear equation:
Vin = Aval * v + Voff with v = (Vref - Voff) / Amax
If we look at the series connection of Rs and Rt we see that
So with this knowledge, we can connect the network Rs/Rt to any voltage Vcc as long as we keep the input voltage Vin smaller than the reference voltage Vref of the ADC.
It is clear from these considerations that quite a lot of parameters must be taken into account. We therefore pack these into two data structures, one for the NTC and one for the ADC. We also take into account that the attenuation only applies to the ESP32.
using ParamsNTC = struct paramsNtc {
uint16_t Rs;
uint16_t Ro;
uint16_t beta; };
using ParamsADC = struct parmsAdc{
uint8_t pin;
bool ntcToGround;
uint16_t Amax;
adc_attenuation_t att; // only relevant for ESP32
double Vcc;
double Vref;
double Voff; };
The NTC sensor only provides one result, namely the temperature. However, if we later want to connect another sensor to the thermostat, e.g. a BME280 or SHT31, which also measure humidity and even air pressure, it is better to save the measurement results in a SensorData data structure that we can adapt to the respective sensor.
In the case of the NTC sensor, we can also store the measured resistance value, the analog voltage, etc. in this structure and thus make it available to the end user.
using SensorData = struct sDat
{
float tCelsius;
float tFahrenheit;
float tKelvin;
double Roo; // resistance for T --> oo
double k; // k = Vin / (Vcc - Voff)
double v; // v = (Vref - Voff) / analogMax
double vin; // input voltage on ADC-pin
double Rt; // calculated resistance at temperature T
uint16_t analogValue; // measured analog value Aval
uint8_t sensorPin; // ADC input pin
const double To = 25.0; // nominal temperature
const double Tabs = -273.15; // absolute temperature
};
Regardless of which type of sensor we use, we always want to initialize them in the same way and always query the measured values in the same way. This suggests the definition of an interface, which we can implement in C++ as a pure abstract class as described above.
Now we can design classes for the sensors, which inherit the interface and
implement the required methods. The constructor of the NTCsensor
class takes
3 references as arguments, one to the parameters of the NTC sensor, the second
to the parameters of the ADC and the third to a data struct where we store the
measured results. The member variables are initialized, the pin mode of the
analog input pin is set and the constant Roo is calculated.
class NTCsensor : public Isensor
{
public:
NTCsensor(ParamsNTC& ntc, ParamsADC& adc, SensorData& sensorData) :
_ntc(ntc), _adc(adc), _sdata(sensorData)
{
pinMode(_adc.pin, INPUT);
#ifdef ESP32
analogSetAttenuation(_adc.att);
#endif
// calculate the resistance of the NTC for T --> oo
_sData.Roo = _ntc.Ro * exp(-(double)_ntc.beta / (_sData.To - _sData.Tabs));
}
void setup();
void readSensor(); // read the sensor and update the measured values
float getCelsius(); // return the temperature in °C
void printData(); // print the measured values
void printParams(); // print the sensors parameters
SensorData& getDataReference(); // get a reference to the sensor data struct
private:
ParamsNTC& _ntc;
ParamsADC& _adc;
SensorData& _sData;
}
Of course, we would like to be able to query the temperature in °C, °F and °K and also output the parameters of the NTC. The corresponding conversions are provided for this purpose.
The temperature is measured periodically in the thermostats loop() method. The measurement interval and the two temperature limits are set with corresponding methods. All other details can be seen in the code.
The program simply outputs the parameter settings and the measured values periodically.
Command Line Interface | Output |
---|---|