Remote heating for a holiday home#
The holiday home is three hours away. You want it warm when you arrive on Friday night, not heated all week for nobody. So an ESP32 sits next to the heating controls with a relay and a thermistor, and serves its own little control page: the room temperature, whether the heating is on, and one big button. The fun part is the thermistor. The free pins on this board have no analogue input, so the sketch measures temperature with a stopwatch.
Warning
The relay switches whatever you connect to it. Don't wire it into mains (230 V) unless you're qualified. The safe version switches a low-voltage control instead, such as the thermostat terminals on a boiler or heater controller, within the relay's rating. Anything mains is a job for an electrician.
Bill of materials#
| Part | Notes |
|---|---|
| ESP32 dev board | Classic ESP32 DevKit |
| 1-channel relay module | One that switches with a 3.3 V input |
| 10 kΩ NTC thermistor | B value 3950 (check the datasheet) |
| 100 nF capacitor | Ceramic, across the thermistor |
| LED + 1 kΩ resistor | Lights while the heating is on |
| Breadboard and jumper wires |
How it works#
phone ──HTTPS──▶ heater-byrne.a.tollan.app
▲
│ the board connects out
ESP32 ──────────────────────┘
GPIO 23 ──▶ relay + LED GPIO 19 ◀── thermistor + 100 nFThe board serves the control page and a small JSON status, and switches the relay when you press the button. Every second, even with nobody watching, it reads the temperature, and it turns the heating off by itself at 40 °C or if the sensor fails.
Reading a thermistor without an ADC. GPIO 19 can't measure voltage, but it can measure time. The sketch drives the pin high to charge the 100 nF capacitor, lets go of the pin, and counts the microseconds until the capacitor has drained through the thermistor far enough to read as low. The drain time is proportional to the thermistor's resistance, and the resistance gives the temperature through the thermistor's B value. Seven readings a second, and the median wins, so one sample stretched by Wi-Fi doesn't matter.
Step 1: Wire it#

| From | To |
|---|---|
| Relay IN | GPIO 23 |
| Relay VCC / GND | 3V3 / GND |
| GPIO 23 → 1 kΩ → LED | GND |
| Thermistor | GPIO 19 and GND |
| 100 nF capacitor | GPIO 19 and GND |
The capacitor is not optional: it's the thing being timed. If your relay module has a 5 V coil, power it from the 5V pin instead, and check that its input still switches with the 3.3 V signal.
Step 2: Get the demo project and an address#
In the console, add a device named heater with the esp32 (Arduino / PlatformIO) architecture, and add a route with Internal host 127.0.0.1, Internal port 80 and a Hostname (SNI) exposure. Then click Download demo project and open it in VS Code with PlatformIO.
Important
The link works once and expires after 15 minutes, and the token inside enrolls one board. Keep src/tollan_config.h out of git until the board has enrolled.
Step 3: The control page#
Save this as src/index.html. It polls /state every three seconds and sends an explicit on or off, so a double click can't flip it back.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Heater</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 22rem; margin: 2rem auto; padding: 0 1rem; text-align: center; }
#temp { font-size: 3rem; margin: .5rem 0; }
button { font-size: 1.2rem; padding: .6rem 1.4rem; margin: .3rem; }
#heat { font-size: 1.5rem; font-weight: bold; }
#heat.on { color: #d9480f; }
#btn { border: 0; border-radius: .4rem; }
#btn.off { background: #c92a2a; color: #fff; }
#btn.on { background: #2b8a3e; color: #fff; }
</style>
</head>
<body>
<h1>Heater</h1>
<div id="temp" aria-live="polite">--</div>
<p id="heat" aria-live="polite">loading…</p>
<p id="cutoff"></p>
<button id="btn" onclick="toggle()" disabled>…</button>
<script>
let on = false;
function show(s) {
on = s.heat;
document.getElementById('temp').textContent = s.tempC === null ? 'sensor fault' : s.tempC + ' °C';
const h = document.getElementById('heat');
h.textContent = 'Heating ' + (on ? 'ON' : 'OFF');
h.className = on ? 'on' : '';
document.getElementById('cutoff').textContent = 'cutoff ' + s.maxC + ' °C';
const b = document.getElementById('btn');
b.textContent = on ? 'Turn heat off' : 'Turn heat on';
b.className = on ? 'on' : 'off';
b.disabled = false;
}
function fail() { document.getElementById('heat').textContent = 'board unreachable'; document.getElementById('heat').className = ''; }
function poll() { fetch('state').then(r => r.json()).then(show).catch(fail); }
// Sends explicit on/off (not a blind toggle) so a double click can't flip it back.
function toggle() { fetch('heat/' + (on ? 'off' : 'on'), { method: 'POST' }).then(r => r.json()).then(show).catch(fail); }
poll();
setInterval(poll, 3000);
</script>
</body>
</html>Add one line to platformio.ini so the page is built into the firmware:
board_build.embed_txtfiles = src/index.htmlStep 4: The sketch#
Replace src/main.cpp with this, fill in your Wi-Fi details (2.4 GHz only), and click Upload.
// Holiday-home heating: an ESP32 switches the heating through a relay and
// serves its own control page at its own HTTPS address.
#include <WiFi.h>
#include <TollanAgent.h>
#include <assert.h>
#include <algorithm>
#include "tollan_config.h" // from the console's demo project
using namespace tollan;
static const char* WIFI_SSID = "your-wifi";
static const char* WIFI_PASS = "your-wifi-password";
TollanAgent agent;
// src/index.html, linked in by board_build.embed_txtfiles (NUL-terminated).
extern const char INDEX_HTML[] asm("_binary_src_index_html_start");
// ---- heater + thermistor ---------------------------------------------------
// GPIO23 -> relay IN, and GPIO23 -> 1 kΩ -> LED -> GND (lights while heating).
// GPIO19 -> thermistor -> GND, plus a 100 nF capacitor from GPIO19 to GND.
// GPIO19 has no ADC, so the thermistor is read by timing: charge the capacitor
// from the pin, let go, and count how long it takes to drain through the
// thermistor. The time is proportional to its resistance.
static const int HEAT_PIN = 23;
static const int HEAT_ON_LEVEL = HIGH; // LOW if your relay module switches on LOW
static const int THERM_PIN = 19;
// Calibration: match your parts, then tune RC_FACTOR against a real thermometer.
static const float CAP_UF = 0.1f; // the capacitor across the thermistor
// Reads hot -> lower RC_FACTOR, reads cold -> raise it: about 4.5% per °C.
static const float RC_FACTOR = 0.66f;
static const float US_PER_OHM = CAP_UF * RC_FACTOR;
static const float NTC_OHMS_25C = 10000; // thermistor's resistance at 25 °C
static const float NTC_BETA = 3950; // thermistor's B value (datasheet)
static const float TEMP_OFFSET_C = -10;
static const float MAX_TEMP_C = 40; // heat is forced off at or above this
// Outside this range the thermistor is shorted or unplugged.
static const float MIN_OHMS = 200, MAX_OHMS = 200000;
static float tempC = NAN; // NAN = sensor fault
static bool heatOn = false;
// Thermistor ohms -> °C via the beta equation; NAN on a sensor fault.
static float ntcCelsius(float ohms) {
if (!(ohms > MIN_OHMS && ohms < MAX_OHMS)) return NAN;
return 1 / (1 / 298.15f + logf(ohms / NTC_OHMS_25C) / NTC_BETA) - 273.15f + TEMP_OFFSET_C;
}
// One measurement: µs for the charged capacitor to drain to the pin's LOW
// threshold, capped where the thermistor counts as unplugged.
static uint32_t drainMicros() {
const uint32_t maxUs = MAX_OHMS * US_PER_OHM;
pinMode(THERM_PIN, OUTPUT);
digitalWrite(THERM_PIN, HIGH);
delayMicroseconds(100); // charges the 100 nF fully
pinMode(THERM_PIN, INPUT); // no pull-up/down: only the thermistor drains it
uint32_t t0 = micros(), dt = 0;
while (digitalRead(THERM_PIN) && (dt = micros() - t0) < maxUs) {}
return dt;
}
static bool heatAllowed() { return !isnan(tempC) && tempC < MAX_TEMP_C; }
static void setHeat(bool on) {
heatOn = on;
digitalWrite(HEAT_PIN, heatOn ? HEAT_ON_LEVEL : !HEAT_ON_LEVEL);
}
// Runs whether or not anyone has the page open, so the cutoff always applies.
static void readTemp() {
uint32_t s[7];
for (uint32_t& v : s) v = drainMicros();
std::nth_element(s, s + 3, s + 7); // median: ignores a sample a task switch stretched
tempC = ntcCelsius(s[3] / US_PER_OHM);
if (heatOn && !heatAllowed()) {
setHeat(false);
agent.log().println(isnan(tempC) ? "heat OFF: sensor fault" : "heat OFF: over max temp");
}
}
// Send one complete HTTP response on a channel, then close it.
static void reply(uint32_t ch, const char* status, const char* type, const char* body) {
size_t len = strlen(body);
char head[160];
int n = snprintf(head, sizeof(head),
"HTTP/1.1 %s\r\nContent-Type: %s\r\nContent-Length: %u\r\n"
"Connection: close\r\n\r\n",
status, type, (unsigned)len);
agent.send(ch, (const uint8_t*)head, n);
for (size_t off = 0; off < len; off += 1024) { // send in 1 KB pieces
agent.send(ch, (const uint8_t*)body + off, len - off < 1024 ? len - off : 1024);
}
agent.closeChannel(ch);
}
static void replyState(uint32_t ch) {
char t[12] = "null";
if (!isnan(tempC)) snprintf(t, sizeof(t), "%.1f", tempC);
char body[80];
snprintf(body, sizeof(body), "{\"tempC\":%s,\"heat\":%s,\"maxC\":%.0f}\n",
t, heatOn ? "true" : "false", MAX_TEMP_C);
reply(ch, "200 OK", "application/json", body);
}
// The request line leads the first chunk of every request, so we route on it.
static void handleRequest(uint32_t ch, const uint8_t* data, size_t len) {
auto starts = [&](const char* p) { size_t n = strlen(p); return len >= n && memcmp(data, p, n) == 0; };
if (starts("GET / ")) return reply(ch, "200 OK", "text/html; charset=utf-8", INDEX_HTML);
if (starts("GET /state ")) return replyState(ch);
// POST, not GET: link previews and crawlers fetch GET URLs and must not flip the heater.
if (starts("POST /heat/on ") || starts("POST /heat/off ")) {
setHeat(starts("POST /heat/on ") && heatAllowed());
agent.log().printf("heat %s (web)\n", heatOn ? "ON" : "OFF");
return replyState(ch);
}
reply(ch, "404 Not Found", "text/plain", "not found\n");
}
void setup() {
Serial.begin(115200);
setHeat(false); // always boot with the heating off: set the level first...
pinMode(HEAT_PIN, OUTPUT); // ...then drive the pin
// Self-check: rated resistance reads 25 °C; shorted or unplugged reads as a fault.
assert(fabsf(ntcCelsius(NTC_OHMS_25C) - TEMP_OFFSET_C - 25) < 0.1f);
assert(isnan(ntcCelsius(0)) && isnan(ntcCelsius(MAX_OHMS)));
readTemp();
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
while (WiFi.status() != WL_CONNECTED) delay(300);
agent.log().printf("wifi ok, ip=%s\n", WiFi.localIP().toString().c_str());
TollanConfig cfg;
cfg.host = TOLLAN_RELAY_HOST;
cfg.port = TOLLAN_RELAY_PORT;
cfg.caCert = CA_CERT;
#ifdef TOLLAN_ENROLL_TOKEN
cfg.enrollUrl = TOLLAN_ENROLL_URL; // first boot: the board makes its key and enrolls
cfg.enrollToken = TOLLAN_ENROLL_TOKEN;
cfg.enrollCaCert = TOLLAN_ENROLL_CA;
#else
cfg.clientCert = DEVICE_CERT;
cfg.clientKey = DEVICE_KEY;
#endif
cfg.renewUrl = TOLLAN_RENEW_URL;
cfg.deviceId = TOLLAN_DEVICE_ID; // health check-in for the console
cfg.targetsUrl = TOLLAN_TARGETS_URL;
agent.onData(handleRequest);
agent.begin(cfg);
}
void loop() {
agent.loop();
static uint32_t lastRead = 0;
if (millis() - lastRead >= 1000) {
lastRead = millis();
readTemp();
}
delay(2);
}The safety rules live in the firmware, not the page:
- It boots with the heating off. A power cut never comes back to a heater running unattended.
- It refuses to heat at or above 40 °C, or when the thermistor reads as shorted or unplugged, and switches off by itself if either happens while heating.
- Switching takes a POST. Link previews and crawlers fetch addresses with GET, so pasting the link in a chat can't turn the heating on.
Step 5: Calibrate#
Put a known-good thermometer next to the thermistor and compare after a few minutes. Every capacitor and pin threshold is a little different, so tune RC_FACTOR: if the board reads hot, lower it; if it reads cold, raise it, about 4.5% per °C. TEMP_OFFSET_C takes up any constant error, and NTC_BETA must match your thermistor's datasheet.
Step 6: Lock it down#
This page switches real heating, so put a password on it before anything else: Password-protect a site. The page's own status and button requests go through the same browser login, so nothing else changes.
Gotchas#
- The relay works the wrong way round. Many modules switch on a low input. Set
HEAT_ON_LEVELtoLOWand re-flash. - "sensor fault" on the page. The thermistor or the capacitor isn't connected, so the drain time is out of range. The heating stays off until it reads again.
- "board unreachable" on the page. The board is offline: check its Wi-Fi, then the Stability tab on its device page for when it dropped.
Where to take it next#
- A schedule: keep a target time in the sketch, and switch on a few hours before you arrive.
- A thermostat: hold a set temperature by switching on below it and off above it, with a degree of hysteresis so the relay doesn't chatter.