Michał Jonik
2026-07-25

A thermostat is not a safety system

My sauna runs on an ESP32 with ESPHome: a DS18B20 probe, a solid-state relay and a thermostat that holds the cabin between 70 and 110 °C. A short press of the button stops heating. Starting it takes a three-second hold, so nobody starts it by brushing past. Home Assistant can start it too, with nobody in the room. It has worked for a year.

When I read the file again, the only safety setting in it was restore_mode: ALWAYS_OFF on the relay: after a reboot, the relay starts off. It looks like a fail-safe, but it covers the relay and nothing that controls it.

What the thermostat does not do

The ESPHome thermostat climate switches the relay around a target with a deadband and minimum on and off times. Outside that job, the ESPHome source leaves four things to you.

It has no limit on how long it heats. There is an option called max_heating_run_time, but it starts a supplemental heating action; it does not switch anything off.

It does not notice a sensor that stops reporting, because it recomputes only on a new value. A probe that fails with a read error publishes NaN, and the thermostat switches off. A sensor that simply goes silent leaves the relay in its last state, and if that state was on, the stove keeps heating on an old reading. ESPHome’s timeout sensor filter can turn that silence into NaN, but it starts only after a first reading, and it protects the thermostat, not a relay switched on from somewhere else.

It may resume heating after a reboot. By default it restores its saved mode from flash, so according to the code, the relay starts off as configured and the thermostat above it comes back in HEAT and switches it on with the first reading. A power blip in the middle of a session is enough.

It knows no temperature limit other than its target.

I found these failure modes by reading the code, not through a failure.

Three layers, each assuming the one above has failed

Home Assistant is the soft layer. It can send a notification when the sauna has been heating for longer than usual, but a Wi-Fi outage or a server restart takes it away.

The board has to work without the network. On the controller, next to the thermostat and independent of it, I added four limits:

  • a hard cut-off at a fixed temperature above the highest target, which latches. The latch is written to flash as soon as it is set, not on ESPHome’s default one-minute schedule, so neither a restart nor a power cut “fixes” an overheat,
  • a sensor timeout: no valid reading for two minutes while heating switches everything off,
  • a run-time limit counted from the moment heating was switched on, not from relay cycles,
  • a safe start: on every boot the relay goes off and the thermostat goes to OFF, so heating always needs a fresh command.

An interlock on the relay switches it off whenever it comes on while the thermostat is off or the latch is set, whoever turned it on.

Because the controller is Firmware-as-Config, its whole behaviour declared in its configuration, the thermostat and the limits can ship together. I put both in one ESPHome package, sauna-controller.yaml, so the thermostat cannot be included without the limits, and both run on the ESP rather than in Home Assistant. The thresholds are parameters. The values in it (115 °C, two minutes, four hours) are examples for my stove and probe position, not numbers from any standard.

The hardware is the last layer, and it knows nothing about YAML. My stove is a single-phase 3.5 kW heater, about 15 A at 230 V, switched by a 40 A AC solid-state relay on an aluminium heatsink under a protective cover. The circuit has two fuses, one next to the sauna and one in the distribution board. They protect the wiring from a short circuit. A stove that keeps heating draws its normal current, so they do not see it. The relay has a wide margin, but a solid-state relay usually fails shorted, and no code can open a shorted relay. A contactor or a manual-reset thermal cut-out in series with the heating element belongs in this layer, and so does the stove’s own temperature limiter, if the manual says it has one. Such a layer keeps working with an empty flash, a crashed board or a bug in my lambda. The resistor that holds the SSR input off while the board is in reset, before ESPHome configures the pin, belongs here too.

Remote start adds a requirement of its own. Nobody looks at the stove before it heats, and a towel left on it is a fire. Heater makers that allow remote start require an extra safety device for it, such as a door switch or a sensor that detects something lying on the stove. The package exposes HEAT to Home Assistant, so that requirement applies to it.

Break it on purpose

The limits run on my controller, and each of them passed a test in which I broke it on purpose. If you build your own, test it on a spare board with a lamp as the load, the cut-off lowered to about 30 °C and the run-time limit to two minutes: unplug the probe while heating, warm it with a hair dryer, cut power mid-session, and check that the relay stays off each time.

This is a 3.5 kW heater in a wooden room. The package adds to the hardware protection and does not replace it. Wiring on 230/400 V belongs to a qualified electrician.

What a fail-safe covers

A fail-safe covers the component it is set on and nothing that controls it. Any automated system that changes something without a person watching, such as a batch job, an integration or a scheduler, has the same gap.

The gate controller keeps its safety in a layer the code cannot switch off. In a company that layer is the limits the changed system enforces itself: database constraints, permissions, quotas. A heater needs two more things from every layer. The safe state is the default after a restart or a lost connection: a stopped job does not stay stopped if its queue resumes on its own after a deploy. And a missing signal is treated as a failure: a sensor that stops reporting and a job that stops logging both need a timeout, and a session or a run needs a time limit.

esphomehome-assistantsafetyfirmware-as-config