My house in Sajkas is 220m2 over two floors: a ground floor with floor heating, an attic with its own zones, and a few fan-coil units. The plan is a hybrid heating system: a Midea heat pump as the main source and a Romstal EcoHeat gas condensing boiler as backup. Both feed a buffer tank, and three circulation pumps move the heat from there into the house.

Off-the-shelf controllers either handle only one heat source, or they don't know anything about Serbian EPS tariffs (cheap night power from 23:00 to 07:00, and green/blue/red zones based on monthly consumption). So I built my own.

This post covers the architecture, the parts I'm happy with, and the hardware surprises that cost me the most time.

The problem

The controller has to decide, at any moment:

  • Is there heat demand? The ground floor thermostat, the attic zone actuators and each fan-coil unit report demand separately.
  • Which source to use? The heat pump is preferred while it's efficient. Below -10C gas is allowed to help, and below -15C gas becomes the main source.
  • How much does power cost right now? Night tariff is 50% of the day price, and the red consumption zone is expensive enough that gas is always worth considering there.
  • Which pumps to run? Only the circuits that actually asked for heat.
  • Is everything safe? Heat pump alarm, gas boiler alarm, flow monitoring, emergency stop, and a runtime watchdog.

All of that must keep working when WiFi is down. Heating that stops because the router rebooted is not an option in January.

Hardware

  • ESP32-S3 (N8R8): dual-core Xtensa LX7, 8MB flash, 8MB embedded Octal PSRAM
  • 8 relays: heat pump enable, gas boiler (TA contact), DHW mode, DHW recirculation pump, ground floor pump, attic pump, fan-coil pump
  • DS18B20 1-Wire probes on the DHW recirculation line and as clamp-on sensors on the buffer supply/return pipes
  • SSD1306 OLED (128x64, I2C) with three rotating status pages
  • Onboard WS2812 RGB LED that shows the boot and connectivity phase
  • SALUS KL06-M wiring center for the attic floor heating zones, with a SALUS PL07 pump-demand module
  • Shelly 1PM Mini Gen3 devices that bridge external demand signals (fan-coil units, attic zone controller) into MQTT

The gas boiler has no Modbus and no OpenTherm, only dry-contact inputs. So the ESP32 controls it the same way a room thermostat would: one relay on the TA terminals. Closed means "I need heat", and the boiler handles modulation, flow temperature and its own DHW priority on its own.

Architecture: two cores, two worlds

The main design rule: the control loop never waits for the network. The ESP32-S3 has two cores, so each one gets its own job:

Core 1 (real-time control):              Core 0 (networking):
  taskControlLoop  [prio 5, 8KB]           taskWiFiManager  [prio 3, 4KB]
    sensors, FSM, relays, safety           taskMQTTClient   [prio 3, 8KB]
    energy calc, 1000ms cycle              taskNTPSync      [prio 2, 4KB]
  taskDisplayUpdate [prio 4, 4KB]          taskOTAHandler   [prio 1, 4KB]
    I2C OLED every 3s

Core 1 reads sensors, runs the state machine and switches relays every second. Core 0 deals with WiFi, MQTT, NTP and OTA updates. The two sides only talk through FreeRTOS queues. An MQTT command (for example "switch to summer mode") goes into a command queue, and the control loop picks it up on its next cycle. Nothing on Core 1 ever blocks on a socket.

State machine

The controller is a finite state machine with clear enter/exit actions:

INIT -> IDLE -> HEATING_SPACE / GAS_SPACE / COOLING_SPACE
                     |
                  DEFROST
        ALARM / EMERGENCY_STOP (from any state)

Every transition has a reason code. The dashboard and the logs always show why the controller did something, not just what it did. That turned out to be the most useful debugging tool in the whole project.

Anti short-cycling

Compressors and burners don't like being switched on and off every minute. The firmware enforces minimum run and stop times: 10 minutes run / 5 minutes stop for the heat pump, 5 minutes for the gas boiler, and a 3-minute delay before the heat pump changes mode so the refrigerant pressure can equalize. There's also a bench test mode that shortens all of these to seconds, so I can test the whole flow on my desk with LEDs instead of real pumps.

Decision logic in pure C, tested on my laptop

The best decision in this project was moving all decision logic into one file, heating_logic.c, written in plain C99 with no Arduino dependencies. Functions get everything as parameters (temperatures, current hour, tariff zone, demand flags) and return a decision:

// Pure functions: no globals, no hardware, time passed in as a parameter
bool atticPumpShouldRun(bool atticDemand, unsigned long demandRoseAtMs, unsigned long nowMs);
bool resolveAtticDemand(bool shellyOnline, bool shellyDemand, bool manualDemand);
bool anySpaceHeatDemand(const SystemStatus *status);

So the same code compiles for the ESP32 and for a native test binary on my Mac. Around 100 Unity unit tests cover tariff windows, heat source selection, defrost, pump gating and reason codes, and the whole suite runs in about a second:

cd arduino/smart-ac-dwh/tests && make -f Makefile.native test

Hardware code (inputs.cpp, outputs.cpp, mqtt_client.cpp) stays thin and only translates between GPIO/MQTT and those pure functions. When I change the heating behaviour, I write a test first and only flash the board once the logic is right.

One example of a bug the tests caught: every hour-based decision (night tariff, for example) was originally made even when the clock wasn't synced yet. After a reboot without internet, millis() would pretend it was 00:something, so the controller thought it was in cheap night tariff. Now all hour-based decisions are gated on ntp_synced.

Demand-driven pumps

The first version simply ran all the pumps all winter. It worked, but it wasted electricity and heat. Now each pump runs only when its own circuit asks for heat:

  • Ground floor: the dry contact of a Romstal RWT15 wireless thermostat receiver, wired straight to a GPIO input.
  • Fan-coils: three Shelly 1PM Mini devices, one per unit. When a unit is on, its Shelly publishes that to MQTT.
  • Attic: a SALUS KL06-M wiring center with a PL07 pump-demand module, bridged to MQTT through a Shelly (details below).

Attic: SALUS KL06-M and PL07

The attic floor heating has 8 loops. 7 of them have thermostatic actuators, grouped into 6 zones on a SALUS KL06-M wiring center. Each zone thermostat tells the KL06-M to open its actuators. The 8th loop (the bathroom) deliberately has no actuator and always stays open, so the pump always has somewhere to push water, even when every other zone is closed.

The problem: the KL06-M itself has no pump-demand output for the ESP32 to read. It only has a serial expansion connector for SALUS modules. Reverse-engineering that proprietary bus wasn't worth it, so I added the SALUS PL07 module. It aggregates all 6 zones into one dry contact: closed means at least one zone is calling for heat.

That contact goes to the SW input of a Shelly 1PM Mini, configured as a "switch" input. The Shelly doesn't switch any load here. It just senses the contact and publishes its state to MQTT (<topic_prefix>/status/input:0). The firmware turns that into the attic demand:

// Attic pump waits for the zone actuators to open first
bool atticPumpShouldRun(bool atticDemand, unsigned long demandRoseAtMs, unsigned long nowMs) {
  if (!atticDemand) {
    return false;
  }
  if (demandRoseAtMs == 0) {
    return false;  // rising edge not recorded yet: treat as still opening
  }
  return (nowMs - demandRoseAtMs) >= (unsigned long)ATTIC_ACTUATOR_OPEN_DELAY_SEC * 1000UL;
}

Thermal actuators take a few minutes to open, so the pump starts 180 seconds after demand appears and stops immediately when it clears. The heat source, on the other hand, is enabled on the raw demand. That way the water is already warm by the time the pump starts.

If the Shelly goes offline (WiFi or MQTT outage), the firmware falls back to a manual override sent over MQTT (cmd/set_attic_demand), so the attic stays controllable.

The gas boiler TA contact closes only when at least one of those three circuits asks for heat, exactly like a real room thermostat would.

Why Shelly devices instead of more wires to the ESP32? Simple: the board ran out of free pins. More on that below.

Backend and dashboard

Everything the controller knows goes to MQTT under smartac/sajkas/:

smartac/sajkas/
  state                    # "IDLE", "HEATING_SPACE", ... (retained)
  mode                     # "WINTER" | "SUMMER" (retained)
  temps/{dhw,dhw/recirc,buffer/supply,buffer/return}
  relays/{hp_enable,gas_boiler,pump_floor_ground,pump_floor_first,pump_fancoil,...}
  demand/{fancoil_1,fancoil_2,fancoil_3,attic}
  energy/{instant_power,daily,monthly,projected,tariff_zone,tariff_period}
  safety/{hp_alarm,gas_alarm,flow_detected,emergency_stop}
  system/{uptime,wifi_rssi,free_heap,ntp_synced,firmware_version,ip}
  cmd/{set_state,set_mode,reboot,set_config,...}

On a home server (an old MacBook Pro), Docker Compose runs four services:

  • Mosquitto: the MQTT broker
  • TimescaleDB: Postgres with time-series hypertables for telemetry and logs
  • NestJS API: an MQTT bridge, REST endpoints and Socket.IO for live updates
  • Nuxt 4 dashboard: the current state, temperature trends, relays grouped by subsystem, history and logs

The whole thing is a pnpm monorepo, and shared TypeScript types live in packages/types, so the backend and the frontend agree on the message format.

Transparent logging

The firmware keeps a ring buffer of 8192 log entries in PSRAM (768 KB), sends every entry to MQTT and the backend writes them into TimescaleDB. The dashboard has a /logs page with live logs from the controller. When something strange happens at 3 AM, I can see exactly what the controller "thought" at that moment.

Hardware gotchas that cost me the most time

Writing the code was the easy part. Here's what really ate my evenings.

Serial completely silent

After flashing, Serial printed nothing, not even the boot banner. It looked like the board hung at boot. A multimeter on the GPIOs proved the app was running fine. The cause: on this board, CDCOnBoot=cdc is required in the FQBN for Serial to work over native USB. Flashing worked anyway, because esptool uses the ROM's own USB path.

A macro that silently "reverted" its value

The RGB LED on my board is on GPIO38, but every build kept driving GPIO48. The source clearly said 38, and the .bin on flash matched the build byte for byte. The cause: I named my macro PIN_RGB_LED, and the ESP32 Arduino core already defines PIN_RGB_LED as 48 in a generic variant header that gets included after mine. The preprocessor allows the redefinition, only with a warning I never saw. Lesson: never name a project macro like a well-known core macro. Now it's called PIN_STATUS_RGB_LED.

Two pins that "die" as soon as you plug in a wire

The OLED never showed up on an I2C scan. GPIO21 and GPIO47 read HIGH while floating, but dropped to LOW as soon as any wire was plugged in. A wire-swap test proved the problem followed the pin and not the wire or the module: most likely a solder bridge to GND on the header. I moved I2C to GPIO40/48 and it worked right away.

PSRAM vs. alarm inputs

When I turned on Octal PSRAM (PSRAM=opi) for the log buffer, the board went into a watchdog reset loop every 5 seconds. espefuse.py confirmed the chip is an ESP32-S3R8 with embedded Octal PSRAM, which reserves GPIO33-37 for its data bus. The heat pump alarm, gas alarm and flow switch were on GPIO35, 36 and 37. pinMode() on those pins silently broke the GPIO matrix, and the next I2C call hung.

On top of that, my first attempt to move them used GPIO22/23, which are valid pins on the chip but don't exist on this board's header. That gave a false gas alarm that looked completely real. Final fix: GPIO7/10/11. The board now has zero spare pins, which is why every new input goes through Shelly and MQTT.

DHT11 that crashed Core 1

I briefly tested a cheap DHT11 for room/outdoor temperature. Its library bit-bangs the protocol with interrupts disabled, and when the sensor didn't respond, Core 1 got Interrupt wdt timeout on CPU1 and the whole controller rebooted in a loop, relays included. It's the same class of problem as "never block the control loop on the network", just coming from a GPIO library instead. Any bit-banged sensor now gets its own low-priority task on Core 0. (That DHT11 later literally melted, but that's another story.)

What I learned

  1. Split decisions from hardware. Pure C functions plus native tests mean I can change the heating logic with confidence, without a board on my desk.
  2. The network is optional, heating isn't. Two cores and queues between them give that guarantee almost for free.
  3. "Valid GPIO on the chip" and "a pin I can use on this board" are two different questions. Check with eFuse tools and a multimeter before you wire anything.
  4. Log the reason, not just the state. A reason code for every transition saved me more time than any debugger.
  5. When you run out of pins, MQTT is your extension bus. Shelly devices as cheap WiFi inputs proved to be a clean solution.

What's next

  • Installing the Midea heat pump and integrating it over Modbus RTU (reading COP, leaving water temperature and defrost status directly from the unit)
  • Outdoor and room temperature from an external WiFi station over MQTT
  • Summer cooling through the buffer tank, once the heat pump is in place
  • Optimizing the run schedule around the night tariff, based on real consumption data from TimescaleDB

If you're building something similar, or you have a question about any part of this, feel free to reach out.