Built-in project · Arduino Uno R3

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.

The circuit runs here once the page has loaded.

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.

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 pinConnects to
Resistor, 220 Ω (3) pin 1Arduino Uno R3 pin 10
Resistor, 220 Ω (2) pin 1Arduino Uno R3 pin 9
Resistor, 220 Ω (1) pin 1Arduino Uno R3 pin 8
Solid-state relay, SSR-25DA / G3MB-202P pin 3Arduino Uno R3 pin 7
Pushbutton pin 1aArduino Uno R3 pin 2
Thermistor, 10k NTC pin 1Arduino Uno R3 pin 5V
Potentiometer pin 3Arduino Uno R3 pin 5V
Resistor, 10k Ω pin 2Arduino Uno R3 pin GND
Potentiometer pin 1Arduino Uno R3 pin GND
Pushbutton pin 2aArduino Uno R3 pin GND
LED, yellow pin CArduino Uno R3 pin GND
LED, green pin CArduino Uno R3 pin GND
LED, red pin CArduino Uno R3 pin GND
Solid-state relay, SSR-25DA / G3MB-202P pin 4Arduino Uno R3 pin GND
Thermistor, 10k NTC pin 2Arduino Uno R3 pin A0
Resistor, 10k Ω pin 1Arduino Uno R3 pin A0
Potentiometer pin 2Arduino Uno R3 pin A1
LED, yellow pin AResistor, 220 Ω (1) pin 2
LED, green pin AResistor, 220 Ω (2) pin 2
LED, red pin AResistor, 220 Ω (3) pin 2
Filament lamp (2) pin 2Solid-state relay, SSR-25DA / G3MB-202P pin 1
Solid-state relay, SSR-25DA / G3MB-202P pin 2Ground
Signal generator pin GNDGround
Filament lamp (1) pin 1Signal generator pin OUT
Filament lamp (2) pin 1Filament 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.

sketch.ino · Arduino Uno R3
// 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 editor

Troubleshooting

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.