Water heater thermostat on an Arduino Uno
A thermistor, a setpoint knob and an SSR-switched lamp for the element: an 8 F hysteresis band plus a latched high-limit cutout, dry-fire protection and a sensor-fault cutout. A teaching model with low-voltage stand-ins, never for a real water heater. The whole build, an Arduino Uno and 14 more parts, runs here in your browser on the firmware below; open it in the editor to change the wiring or the code and run it again.
Teaching? Assign it to your class in one click.
Advanced About 45 minutes to build and run. Runs in your browser. Free, and no account needed.
The parts retail for about $40. On Mokxi they're free.
What you'll learn
- Sensors
- Choosing and reading a resistor
- Reading a knob with an analog pin
- Reading a button
- How an LED lights and why it needs a resistor
- Difficulty: Advanced
- Time: About 45 minutes
- Board: Arduino Uno R3
- Parts: 15
How it works
A thermistor, a setpoint knob and an SSR-switched lamp for the element: an 8 F hysteresis band plus a latched high-limit cutout, dry-fire protection and a sensor-fault cutout. A teaching model with low-voltage stand-ins, never for a real water heater.
Arduino Uno R3 runs the sketch below. The circuit around it: Thermistor, 10k NTC, Resistor, 10k Ω, Potentiometer, Pushbutton, Resistor, 220 Ω, LED, yellow. pinMode sets PIN_RESET as an input with its pull-up on. digitalWrite drives PIN_SSR LOW. digitalRead reads whether PIN_RESET is HIGH or LOW. analogRead measures the voltage on A0 as a number. Serial.begin opens the serial port at 115200 baud.
Parts list
15 parts, plus the jumper wires and a bench instrument. The parts retail for about $40; on Mokxi they're free, and so are the instruments.
- 1 × Arduino Uno R3In Starter Kit, Claw Machine Kit, Vending Machine Kit.
- 1 × Full-size breadboard
- 1 × Thermistor, 10k NTC
- 1 × Resistor, 10k ΩIn Starter Kit, Claw Machine Kit, Halloween Prop Kit.
- 1 × PotentiometerIn Halloween Prop Kit, Starter Projects Kit.
- 1 × PushbuttonIn Starter Kit, Christmas Lights Kit, Logic Lab Kit.
- 3 × Resistor, 220 ΩIn Starter Kit, Claw Machine Kit, Halloween Prop Kit.
- 1 × LED, yellowIn Starter Kit, Halloween Prop Kit, Logic Lab Kit.
- 1 × LED, greenIn Starter Kit, Halloween Prop Kit, Logic Lab Kit.
- 1 × LED, redIn Starter Kit, Halloween Prop Kit, Logic Lab Kit.
- 1 × Solid-state relay, SSR-25DA / G3MB-202P
- 1 × Signal generator
- 2 × Filament lampIn Power Bank Kit.
Wiring table
Every connection, pin by pin, read from the circuit itself. Each row joins the pin on the left to the pin on the right, by a jumper wire or a breadboard strip.
| Part and pin | Connects to |
|---|---|
| Resistor, 220 Ω (3) pin 1 | Arduino Uno R3 pin 10 |
| Resistor, 220 Ω (2) pin 1 | Arduino Uno R3 pin 9 |
| Resistor, 220 Ω (1) pin 1 | Arduino Uno R3 pin 8 |
| Solid-state relay, SSR-25DA / G3MB-202P pin 3 | Arduino Uno R3 pin 7 |
| Pushbutton pin 1a | Arduino Uno R3 pin 2 |
| Thermistor, 10k NTC pin 1 | Arduino Uno R3 pin 5V |
| Potentiometer pin 3 | Arduino Uno R3 pin 5V |
| Resistor, 10k Ω pin 2 | Arduino Uno R3 pin GND |
| Potentiometer pin 1 | Arduino Uno R3 pin GND |
| Pushbutton pin 2a | Arduino Uno R3 pin GND |
| LED, yellow pin C | Arduino Uno R3 pin GND |
| LED, green pin C | Arduino Uno R3 pin GND |
| LED, red pin C | Arduino Uno R3 pin GND |
| Solid-state relay, SSR-25DA / G3MB-202P pin 4 | Arduino Uno R3 pin GND |
| Thermistor, 10k NTC pin 2 | Arduino Uno R3 pin A0 |
| Resistor, 10k Ω pin 1 | Arduino Uno R3 pin A0 |
| Potentiometer pin 2 | Arduino Uno R3 pin A1 |
| LED, yellow pin A | Resistor, 220 Ω (1) pin 2 |
| LED, green pin A | Resistor, 220 Ω (2) pin 2 |
| LED, red pin A | Resistor, 220 Ω (3) pin 2 |
| Filament lamp (2) pin 2 | Solid-state relay, SSR-25DA / G3MB-202P pin 1 |
| Solid-state relay, SSR-25DA / G3MB-202P pin 2 | Ground |
| Signal generator pin GND | Ground |
| Filament lamp (1) pin 1 | Signal generator pin OUT |
| Filament lamp (2) pin 1 | Filament lamp (1) pin 2 |
The code
The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.
// Water heater thermostat with safety cutouts: an NTC thermistor reads the
// tank, a knob sets the temperature, and a solid-state relay switches a lamp
// load that stands in for the heating element.
//
// TEACHING MODEL ONLY. Never wire this to a real water heater, a boiler, a
// gas valve or a 240 V element. The "element" here is two 12 V lamps on
// 24 V AC. A real heater has its own listed thermostat, a manual-reset
// high-limit (ECO) and a relief valve, and mains wiring is work for a
// licensed electrician.
//
// A0 thermistor junction: 5 V -> NTC 10k -> A0 -> 10k -> GND
// A1 setpoint knob (5 V and GND on its ends)
// pin 2 RESET button to GND (the chip's pull-up holds it HIGH)
// pin 7 SSR input (3+), the SSR's 4- to GND; its load side switches the lamps
// pin 8 HEATING LED (yellow), 220 ohms to GND
// pin 9 OK LED (green), 220 ohms to GND
// pin 10 FAULT LED (red), 220 ohms to GND
//
// Temperature: the divider reads higher as the NTC warms (its resistance
// falls). The sketch turns the reading into the thermistor's resistance and
// then into kelvin with the beta equation, and prints degrees F. A bad
// reading (open or shorted sensor) is detected from the raw count, before
// any math.
//
// Thermostat: the knob sets 100 to 140 F. 120 F is the commonly recommended
// setting: hotter water scalds quickly (a few seconds at 140 F can burn
// skin, especially for children and older adults), colder than 120 F makes
// it easier for bacteria such as Legionella to grow in the tank. With an
// 8 F hysteresis band the element comes on at setpoint minus 8 and goes
// off at the setpoint, so it does not chatter around one number.
//
// Safety layers (each one overrides the thermostat):
// 1. High limit (ECO): at 170 F or more the element is cut and latched
// off. It stays off until RESET is pressed AND the tank is below
// 140 F, like the red reset button on a real heater. A real ECO is a
// separate thermal switch in the power path that works even if the
// controller or sensor fails. This code is only a second line of
// defence, never the only one.
// 2. Dry fire: if the element has been on for 10 minutes and the tank has
// not warmed by at least 2 F, assume a dry tank, a failed element or
// a bad sensor. Cut it and latch DRY-FIRE until RESET. (The minutes
// are scaled: 1 second here is 1 minute, so the model is quick to
// watch. Real limits depend on the tank size and element wattage.)
// 3. Sensor fault: a reading near 0 or near 1023 counts is an open or
// shorted sensor. The element is forced off while it lasts, and the
// heater resumes by itself when the sensor reads sanely again.
const int PIN_RESET = 2;
const int PIN_SSR = 7;
const int PIN_HEATING = 8;
const int PIN_OK = 9;
const int PIN_FAULT = 10;
const float R_FIXED = 10000.0; // the fixed resistor to ground
const float R_NTC_25C = 10000.0; // the NTC at 25 C
const float BETA = 3950.0;
const int SETPOINT_MIN_F = 100;
const int SETPOINT_MAX_F = 140;
const int SETPOINT_RECOMMENDED_F = 120;
const float HYSTERESIS_F = 8.0; // element on at setpoint - 8, off at setpoint
const float ECO_TRIP_F = 170.0; // high limit: cut and latch
const float ECO_RESET_BELOW_F = 140.0;
const unsigned long MS_PER_MINUTE = 1000; // scaled time: 1 s is 1 minute
const unsigned long DRY_WINDOW_MIN = 10; // element on this long ...
const float DRY_MIN_RISE_F = 2.0; // ... must warm the tank this much
const int RAW_SHORT = 8; // at or below: sensor reads as a short
const int RAW_OPEN = 1015; // at or above: sensor reads as open
bool element = false;
bool ecoLatched = false;
bool dryFault = false;
bool sensorFault = false;
int setpoint = SETPOINT_RECOMMENDED_F;
float tempF = 70.0;
unsigned long heatStartMs = 0;
float heatStartF = 0;
unsigned long lastReport = 0;
unsigned long lastSample = 0;
bool lastButton = HIGH;
unsigned long buttonChangedAt = 0;
void setElement(bool on) {
if (on == element) return;
element = on;
digitalWrite(PIN_SSR, on ? HIGH : LOW);
if (on) {
heatStartMs = millis();
heatStartF = tempF;
Serial.print("ELEMENT ON at ");
} else {
Serial.print("ELEMENT OFF at ");
}
Serial.print(tempF, 1);
Serial.println("F");
}
// Averages 16 conversions of A0. Returns false for an open or shorted sensor.
bool readTemperature() {
long sum = 0;
for (int i = 0; i < 16; i++) sum += analogRead(A0);
float raw = sum / 16.0;
if (raw <= RAW_SHORT || raw >= RAW_OPEN) return false;
// The NTC is on top, so the lower resistor sees raw/1023 of the supply.
float rNtc = R_FIXED * (1023.0 - raw) / raw;
float kelvin = 1.0 / (log(rNtc / R_NTC_25C) / BETA + 1.0 / 298.15);
tempF = (kelvin - 273.15) * 1.8 + 32.0;
return true;
}
void readSetpoint() {
long sum = 0;
for (int i = 0; i < 16; i++) sum += analogRead(A1);
float exact = SETPOINT_MIN_F + (sum / 16.0) / 1023.0 * (SETPOINT_MAX_F - SETPOINT_MIN_F);
// A dead band keeps the last whole degree until the knob really moves.
if (exact > setpoint + 0.6 || exact < setpoint - 0.6) {
setpoint = (int)(exact + 0.5);
Serial.print("SETPOINT ");
Serial.print(setpoint);
Serial.print("F (band ");
Serial.print(setpoint - (int)HYSTERESIS_F);
Serial.print(" to ");
Serial.print(setpoint);
Serial.println("F)");
if (setpoint > SETPOINT_RECOMMENDED_F) {
Serial.println("NOTE: above 120F raises the scald risk");
}
}
}
void checkReset() {
bool now = digitalRead(PIN_RESET);
if (now != lastButton && millis() - buttonChangedAt > 30) {
buttonChangedAt = millis();
lastButton = now;
if (now != LOW) return; // act on the press, not the release
if (!ecoLatched && !dryFault) {
Serial.println("RESET: nothing is latched");
} else if (sensorFault) {
Serial.println("RESET refused: sensor fault");
} else if (tempF >= ECO_RESET_BELOW_F) {
Serial.print("RESET refused: tank is ");
Serial.print(tempF, 1);
Serial.println("F, must be below 140F");
} else {
ecoLatched = false;
dryFault = false;
Serial.println("RESET: latches cleared");
}
}
}
void control() {
bool good = readTemperature();
if (!good) {
if (!sensorFault) {
sensorFault = true;
setElement(false);
Serial.println("SENSOR FAULT: open or shorted, element forced off");
}
return;
}
if (sensorFault) {
sensorFault = false;
Serial.println("SENSOR OK again");
}
// 1. High limit, latched.
if (!ecoLatched && tempF >= ECO_TRIP_F) {
ecoLatched = true;
setElement(false);
Serial.print("ECO TRIP at ");
Serial.print(tempF, 1);
Serial.println("F: latched off until RESET and tank below 140F");
}
// 2. Dry fire: on for the whole window with too little rise.
if (element && millis() - heatStartMs >= DRY_WINDOW_MIN * MS_PER_MINUTE) {
if (tempF - heatStartF < DRY_MIN_RISE_F) {
dryFault = true;
setElement(false);
Serial.print("DRY FIRE FAULT: element on ");
Serial.print(DRY_WINDOW_MIN);
Serial.print(" min with only ");
Serial.print(tempF - heatStartF, 1);
Serial.println("F rise, dry tank or failed element or sensor");
} else {
heatStartMs = millis(); // it is warming: start the next window
heatStartF = tempF;
}
}
if (ecoLatched || dryFault) {
setElement(false);
return;
}
// 3. The thermostat itself.
if (!element && tempF <= setpoint - HYSTERESIS_F) setElement(true);
else if (element && tempF >= setpoint) setElement(false);
}
void setup() {
Serial.begin(115200);
Serial.println("Mokxi Arduino Uno: water heater thermostat (teaching model)");
Serial.println("Never wire this to a real water heater, gas valve or 240 V element");
Serial.println("120F is the commonly recommended setting; hotter water scalds");
pinMode(PIN_RESET, INPUT_PULLUP);
pinMode(PIN_SSR, OUTPUT);
pinMode(PIN_HEATING, OUTPUT);
pinMode(PIN_OK, OUTPUT);
pinMode(PIN_FAULT, OUTPUT);
digitalWrite(PIN_SSR, LOW);
readTemperature();
}
void loop() {
checkReset();
if (millis() - lastSample >= 100) {
lastSample = millis();
readSetpoint();
control();
}
bool fault = ecoLatched || dryFault || sensorFault;
digitalWrite(PIN_HEATING, element ? HIGH : LOW);
digitalWrite(PIN_FAULT, fault ? HIGH : LOW);
digitalWrite(PIN_OK, fault ? LOW : HIGH);
if (millis() - lastReport >= 1000) {
lastReport = millis();
Serial.print("temp ");
if (sensorFault) Serial.print("---");
else Serial.print(tempF, 1);
Serial.print("F set ");
Serial.print(setpoint);
Serial.print("F element ");
Serial.print(element ? "ON" : "OFF");
Serial.print(" state ");
if (sensorFault) Serial.println("SENSOR-FAULT");
else if (ecoLatched) Serial.println("ECO-LATCH");
else if (dryFault) Serial.println("DRY-FIRE");
else if (element) Serial.println("HEATING");
else Serial.println("OK");
}
}
Code walkthrough
The key functions of the sketch, and what each one does.
setup sketch.ino
Runs once when the board starts. It gets the pins and libraries ready.
void setup() {
Serial.begin(115200);
Serial.println("Mokxi Arduino Uno: water heater thermostat (teaching model)");
Serial.println("Never wire this to a real water heater, gas valve or 240 V element");
Serial.println("120F is the commonly recommended setting; hotter water scalds");
pinMode(PIN_RESET, INPUT_PULLUP);
pinMode(PIN_SSR, OUTPUT);
pinMode(PIN_HEATING, OUTPUT);
pinMode(PIN_OK, OUTPUT);
pinMode(PIN_FAULT, OUTPUT);
digitalWrite(PIN_SSR, LOW);
readTemperature();
}loop sketch.ino
Runs over and over for as long as the board has power. This is where the project does its work.
void loop() {
checkReset();
if (millis() - lastSample >= 100) {
lastSample = millis();
readSetpoint();
control();
}
bool fault = ecoLatched || dryFault || sensorFault;
digitalWrite(PIN_HEATING, element ? HIGH : LOW);
digitalWrite(PIN_FAULT, fault ? HIGH : LOW);
digitalWrite(PIN_OK, fault ? LOW : HIGH);
if (millis() - lastReport >= 1000) {
lastReport = millis();
Serial.print("temp ");
if (sensorFault) Serial.print("---");
else Serial.print(tempF, 1);
Serial.print("F set ");
Serial.print(setpoint);
Serial.print("F element ");
Serial.print(element ? "ON" : "OFF");
Serial.print(" state ");
if (sensorFault) Serial.println("SENSOR-FAULT");
else if (ecoLatched) Serial.println("ECO-LATCH");
else if (dryFault) Serial.println("DRY-FIRE");
else if (element) Serial.println("HEATING");
else Serial.println("OK");
// ... 2 more lines in the editorTroubleshooting
The usual mistakes with this circuit, and the fix for each.
- The temperature reads far off or jumps around. Thermistor
Use a fixed resistor equal to the thermistor's 10k at 25 C in the divider. Convert with the Steinhart-Hart or Beta equation (Beta is about 3950 for common parts), not a straight line. - The reading goes the wrong way, or self-heating shifts it. Thermistor
An NTC lowers its resistance as it warms, so the divider voltage moves with the wiring side. Check which side the thermistor is on. Keep current small, with 3.3 V or 5 V through 10k, so it does not self-heat. - The LED is dim, burns out, or the pin reads wrong because the resistor value is off. Resistor
Read the color bands again. A 220 ohm to 330 ohm resistor suits a typical LED on 5 V. 10k is a common pull-up or pull-down value. Check with a multimeter if unsure. - A pull-up or pull-down resistor is missing, so the input floats and reads random values. Resistor
Tie the input to 5 V or ground through about 10k. Or use INPUT_PULLUP and wire the switch to ground. - The reading jumps around or only covers part of the range. Potentiometer
Wire the two outer legs to 5 V and ground and the middle leg (the wiper) to an analog pin. Using the wrong two legs gives a dead or partial range. - Turning the knob does nothing, or the value is stuck at 0 or 1023. Potentiometer
Check that the wiper goes to an analog input (A0 to A5 on an Uno), not a digital pin. Make sure both ends are connected, since a floating end gives noise. - The input reads random HIGH and LOW when the button is not pressed. Pushbutton
A floating pin picks up noise. Add a 10 k pull-down or pull-up resistor, or use INPUT_PULLUP and wire the button between the pin and GND. Then pressed reads LOW. - The button seems always pressed, or never changes. Pushbutton
A 4-leg tactile switch has legs joined in pairs. Place it across the breadboard center gap so the pairs sit on different rows. Wired along one side, the legs are already connected.
Change it and keep it
Open it in the editor, change the circuit or the code, and keep your version in a free account.