Solar thermal panels on a roof connected to a hot water storage boiler

Why Home Assistant for This Application

Home Assistant logo Built with Home Assistant integration

Home Assistant is an open-source home and building automation platform that runs locally on your hardware (a small server, a NUC, or a Raspberry Pi). It integrates hundreds of devices and protocols through a standardised MQTT-based discovery mechanism, giving you a unified dashboard, automation rules, and historical data logging without depending on any cloud service.

Originally a residential tool, Home Assistant has matured into a platform that professionals take seriously. Its local-first architecture means no subscription fees and no cloud dependency. Its MQTT integration is standards-compliant and works with any broker. And its active development community means the platform moves faster than most proprietary building management systems. Engineers in light commercial, agricultural, and small industrial settings increasingly use it as an affordable SCADA layer where a full SCADA licence cannot be justified.

The EQSP32 integrates with Home Assistant natively through MQTT auto-discovery. The control logic runs entirely on the EQSP32. Home Assistant receives data and sends user commands, but is not in the control loop. If the network drops, the EQSP32 continues operating autonomously using its last configuration. Home Assistant reconnects and resumes when the network recovers.

Solar water heater system overview showing solar panels, circulation pump, boiler with backup heater, and EQSP32 controller
System overview. Solar panels heat a glycol circuit that transfers heat to the boiler via a heat exchanger. The EQSP32 reads two NTC thermistors and controls the circulation pump and backup resistive heater. All sensor data and control switches are exposed to Home Assistant over MQTT.

How the System Works

Solar collectors on the roof heat a fluid (typically a water-glycol mixture) that circulates through the system. A circulation pump moves the heated fluid to a heat exchanger inside the boiler, transferring heat to the stored water. When solar input is insufficient (overnight or during overcast periods), a backup resistive heater can be switched on manually from Home Assistant to maintain the target water temperature.

Two NTC thermistors provide the measurements the control logic depends on: one immersed in the boiler water, one mounted on the solar collector. The pump runs only when the collector is significantly hotter than the boiler and the boiler is below its maximum safe temperature. The heater can only run while the pump is off; the two loads are mutually exclusive by design.

I/O and Wiring

The EQSP32 is powered from a 24 V DC DIN-rail supply fed from mains AC. The 5 V output pin powers the thermistor voltage dividers. Both NTC sensors connect to analog input pins configured as temperature inputs (TIN mode). The pump and heater each connect via a 24 V relay coil to a relay-configured output pin. The relay contacts switch the load-side circuits independently.

SignalPinTypeNotes
Boiler thermistor1Temp in (TIN)10 kΩ NTC, B=3988, 5 V excitation from Vout pin
Panel thermistor2Temp in (TIN)10 kΩ NTC, B=3988, 5 V excitation from Vout pin
Resistive heater relay3Relay out24 V coil, PWM-derated hold at 50% after 1 s
Circulation pump relay4Relay out24 V coil, PWM-derated hold at 50% after 1 s

NTC thermistor selection. Any standard 10 kΩ NTC with a B-value of approximately 3988 K will work with the configuration shown. Waterproof stainless-steel immersion probe types are suitable for direct boiler installation. A surface-mount clip-on type works for the collector pipe. The B-value and nominal resistance can be adjusted in configTIN() to match any other thermistor.

Wiring diagram showing EQSP32 with two NTC thermistors on analog inputs and two relay outputs driving heater and pump contactors, powered from 24V DC supply
Wiring diagram. Both thermistors connect to the upper ADIO terminals (pins 1 and 2) with 5 V excitation from the Vout pin and ground reference from GND. The heater and pump relays connect to DIO terminals (pins 3 and 4) on the lower row. A single 24 V DC supply powers the controller and relay coils.

Control Logic

The firmware runs a continuous loop with three sequential decisions: pump control, heater control, and Home Assistant update. All thresholds use hysteresis to prevent relay chatter at the boundary conditions.

Control flow chart showing pump decision logic, heater decision logic, and Home Assistant update sequence
Control flow. Each loop iteration reads both temperatures, evaluates pump state, evaluates heater state, then updates Home Assistant. The two outputs are mutually exclusive: the heater can only run while the pump is off.

Pump control

The pump runs when the collector is at least 10 °C hotter than the boiler (with 2 °C hysteresis to prevent rapid cycling) and the boiler is below 60 °C. If the boiler reaches 60 °C, the pump stops regardless of collector temperature: the boiler is at its maximum safe temperature and no further heat transfer is needed.

Heater control

The resistive heater is manual-only: it will not switch on automatically. The user enables it via a virtual switch in Home Assistant. Even when enabled, the heater only operates if the pump is not running and the boiler is below 45 °C. It switches off when the boiler reaches 45 °C and only switches back on if the boiler drops below 33 °C (35 °C minus 2 °C hysteresis). This prevents the heater and the solar circuit from working against each other.

Mutual exclusion is enforced in firmware, not just logic. The else branch in the heater control block explicitly sets the heater output to zero whenever the pump is running. Even if the manual switch is on in Home Assistant, the heater relay will not energise while the pump is active.

Home Assistant Integration

Home Assistant dashboard showing the EQSP32 solar water heater entities: boiler temperature, panel temperature, pump state, heater state, and manual heater control switch
The EQSP32 appears automatically in Home Assistant via MQTT auto-discovery. Sensor entities (boiler temperature, panel temperature, relay states) update in real time. The manual heater control switch sends commands back to the controller over MQTT.

The EQSP32 library handles MQTT connection, auto-discovery, and entity synchronisation in the background. In the firmware, you declare each entity with a single function call in setup() and update it with another in loop(). The library manages reconnection automatically. If the MQTT broker is temporarily unreachable, the controller continues running its control logic and the connection resumes transparently when the broker becomes available again.

What happens if Wi-Fi drops. The EQSP32 continues executing the control loop locally. The manual heater switch state is held at its last known value. When connectivity recovers, the EQSP32 reconnects to the broker, republishes its current state, and resumes receiving commands. No manual reset is required.

Software architecture diagram showing EQSP32 control loop, MQTT layer, and Home Assistant entity mapping
Software architecture. The control loop runs entirely on the EQSP32. Home Assistant receives sensor data and sends control commands over MQTT. If the network is unavailable, the EQSP32 continues operating using its last known state.

Automation Code and Home Assistant Integration

The following sections walk through the complete firmware, one block at a time. The full code is assembled at the end.

1. Includes and initialisation

The EQSP32 library provides all hardware abstraction, background network tasks, and MQTT handling. A single include and one object instance are all that is needed.

#include "EQSP32.h"

EQSP32 eqsp32;

2. Pin assignments

Pin numbers map directly to EQSP32 terminal labels. Pins 1 and 2 are configured as analog temperature inputs; pins 3 and 4 are configured as relay outputs.

#define PIN_BOILER_TEMP  1   // NTC thermistor — boiler immersion probe
#define PIN_PANEL_TEMP   2   // NTC thermistor — solar collector surface
#define PIN_HEATER       3   // Relay — resistive backup heater
#define PIN_PUMP         4   // Relay — glycol circulation pump

3. Temperature thresholds and entity names

All decision boundaries are declared as named constants so they can be adjusted without searching the logic code. Home Assistant entity names are declared as strings; these become the display names in the HA dashboard automatically.

// Temperature thresholds (degrees C)
const float HYSTERESIS       = 2.0;   // Buffer to prevent relay chatter at boundaries
const float TEMP_DIFF_ON     = 10.0;  // Collector must exceed boiler by this to start pump
const float BOILER_MAX_TEMP  = 60.0;  // Pump stops above this — boiler at safe maximum
const float HEATER_ON_TEMP   = 35.0;  // Heater switches on below this (when manually enabled)
const float HEATER_OFF_TEMP  = 45.0;  // Heater switches off above this

// Home Assistant entity names (appear as-is in the HA dashboard)
std::string haBoilerTemp    = "Boiler Temperature";
std::string haPanelTemp     = "Panel Temperature";
std::string haHeater        = "Electric Heater";
std::string haPump          = "Circulation Pump";
std::string haHeaterControl = "Heater Control";  // Virtual switch — manual heater enable
std::string haHeatLimit     = "Heat Limit";       // Binary sensor — boiler at max temp

4. Setup: I/O configuration and Home Assistant entity creation

In setup(), each pin is configured for its signal type and each Home Assistant entity is declared. The library handles MQTT discovery registration automatically, with no manual HA configuration required.

ADIO configuration: TIN mode reads an NTC thermistor using the Steinhart-Hart equation. configTIN() takes the B-value and nominal resistance of the thermistor at 25 °C. For a standard 10 kΩ NTC with B = 3988 K (the most common type), no other parameters are needed. RELAY mode applies a full-power pull-in followed by a 50% PWM hold after 1 second, reducing coil power and temperature during sustained operation.

void setup() {
    EQSP32Configs cfg;
    cfg.devSystemID = "SolarHeaterV1";
    eqsp32.begin(cfg, true);

    // Temperature inputs — 10 kΩ NTC, B = 3988 K
    eqsp32.pinMode(PIN_BOILER_TEMP, TIN);
    eqsp32.configTIN(PIN_BOILER_TEMP, 3988, 10000);

    eqsp32.pinMode(PIN_PANEL_TEMP, TIN);
    eqsp32.configTIN(PIN_PANEL_TEMP, 3988, 10000);

    // Relay outputs — 100% pull-in for 1 s, then 50% PWM hold
    eqsp32.pinMode(PIN_HEATER, RELAY);
    eqsp32.configRELAY(PIN_HEATER, 500, 1000);

    eqsp32.pinMode(PIN_PUMP, RELAY);
    eqsp32.configRELAY(PIN_PUMP, 500, 1000);

Home Assistant entity creation: each call registers one entity in Home Assistant via MQTT discovery. Display sensors appear as read-only tiles; binary sensors appear as on/off indicators; control switches appear as interactive toggles the user can operate from the HA dashboard or mobile app.

    // Read-only sensor entities (temperature values)
    createDisplay_Sensor(haBoilerTemp, 1, "°C", "mdi:thermometer", "temperature");
    createDisplay_Sensor(haPanelTemp,  1, "°C", "mdi:thermometer", "temperature");

    // Read-only binary sensors (relay states and safety flag)
    createDisplay_BinarySensor(haHeater,    "mdi:heating-coil");
    createDisplay_BinarySensor(haPump,      "mdi:water-pump");
    createDisplay_BinarySensor(haHeatLimit, "mdi:alert", "heat");

    // Interactive control switch (user enables/disables heater from HA)
    createControl_Switch(haHeaterControl, "mdi:toggle-switch");

    // Publish safe initial state
    eqsp32.pinValue(PIN_HEATER, 0);
    eqsp32.pinValue(PIN_PUMP,   0);
    updateDisplay_Sensor(haBoilerTemp,         0);
    updateDisplay_Sensor(haPanelTemp,          0);
    updateDisplay_BinarySensor(haHeater,       false);
    updateDisplay_BinarySensor(haPump,         false);
    updateDisplay_BinarySensor(haHeatLimit,    false);
    updateControl_Switch(haHeaterControl,      false);
}

5. Loop: circulation pump control

At each iteration the controller reads both temperatures and the current state of the manual heater switch from Home Assistant. The pump decision runs first. The 2 °C hysteresis band around both the temperature difference threshold and the boiler maximum prevents the pump relay from chattering when temperatures hover at the boundary.

Note the / 10.0 on the TIN reads: the library returns temperature as an integer in tenths of a degree (Celsius × 10) to preserve one decimal of precision without using floats in the raw value. Dividing by 10.0 converts it to the familiar Celsius float used in the logic.

void loop() {
    float boilerTemp   = eqsp32.readPin(PIN_BOILER_TEMP) / 10.0;
    float panelTemp    = eqsp32.readPin(PIN_PANEL_TEMP)  / 10.0;
    bool  manualHeater = readControl_Switch(haHeaterControl);
    bool  pumpRunning  = (eqsp32.readPin(PIN_PUMP) > 0);

    // Circulation pump — runs when collector is hot enough and boiler is below max
    if (boilerTemp < BOILER_MAX_TEMP - HYSTERESIS) {
        if (panelTemp > boilerTemp + TEMP_DIFF_ON + HYSTERESIS) {
            eqsp32.pinValue(PIN_PUMP, 1000); // Collector sufficiently hotter — start pump
        } else if (panelTemp < boilerTemp + TEMP_DIFF_ON) {
            eqsp32.pinValue(PIN_PUMP, 0);    // Differential dropped — stop pump
        }
        // Between the two thresholds: no change (hysteresis hold)
    } else if (boilerTemp > BOILER_MAX_TEMP) {
        eqsp32.pinValue(PIN_PUMP, 0);        // Boiler at maximum — stop pump
    }

6. Loop: resistive heater control

The heater only operates when two conditions are simultaneously true: the user has enabled it via the Home Assistant switch, and the pump is not running. If either condition is false, the heater is forced off. This ensures the two heating sources never operate simultaneously and that the user cannot accidentally leave the heater running while the solar circuit is active.

    // Resistive heater — manual only, interlocked with pump
    if (manualHeater && !pumpRunning) {
        if (boilerTemp > HEATER_OFF_TEMP) {
            eqsp32.pinValue(PIN_HEATER, 0);      // Above upper threshold — turn off
        } else if (boilerTemp < HEATER_ON_TEMP - HYSTERESIS) {
            eqsp32.pinValue(PIN_HEATER, 1000);   // Below lower threshold — turn on
        }
        // Between thresholds: no change (hysteresis hold)
    } else {
        eqsp32.pinValue(PIN_HEATER, 0); // Manual switch off or pump running — heater off
    }

7. Loop: update Home Assistant

After outputs are set, the current values of all sensors and states are published to Home Assistant. The haHeatLimit binary sensor activates when the boiler has reached its maximum safe temperature for solar input, or when the resistive heater was manually enabled but the boiler has already reached the heater's upper threshold, informing the user that no further heating is currently possible.

    // Heat limit flag — true when boiler is at a safety threshold
    bool heatLimitActive =
        (manualHeater && !pumpRunning && boilerTemp > HEATER_OFF_TEMP) ||
        (boilerTemp > BOILER_MAX_TEMP &&
         panelTemp  > boilerTemp + TEMP_DIFF_ON + HYSTERESIS);

    // Publish all values to Home Assistant
    updateDisplay_Sensor(haBoilerTemp,         boilerTemp);
    updateDisplay_Sensor(haPanelTemp,          panelTemp);
    updateDisplay_BinarySensor(haHeater,       eqsp32.readPin(PIN_HEATER) > 0);
    updateDisplay_BinarySensor(haPump,         eqsp32.readPin(PIN_PUMP)   > 0);
    updateDisplay_BinarySensor(haHeatLimit,    heatLimitActive);
}

Complete code

The full sketch assembles all of the above into a single file ready to compile in the Arduino IDE with the EQSP32 library installed.

#include "EQSP32.h"

EQSP32 eqsp32;

#define PIN_BOILER_TEMP  1
#define PIN_PANEL_TEMP   2
#define PIN_HEATER       3
#define PIN_PUMP         4

const float HYSTERESIS       = 2.0;
const float TEMP_DIFF_ON     = 10.0;
const float BOILER_MAX_TEMP  = 60.0;
const float HEATER_ON_TEMP   = 35.0;
const float HEATER_OFF_TEMP  = 45.0;

std::string haBoilerTemp    = "Boiler Temperature";
std::string haPanelTemp     = "Panel Temperature";
std::string haHeater        = "Electric Heater";
std::string haPump          = "Circulation Pump";
std::string haHeaterControl = "Heater Control";
std::string haHeatLimit     = "Heat Limit";

void setup() {
    EQSP32Configs cfg;
    cfg.devSystemID = "SolarHeaterV1";
    eqsp32.begin(cfg, true);

    eqsp32.pinMode(PIN_BOILER_TEMP, TIN);
    eqsp32.configTIN(PIN_BOILER_TEMP, 3988, 10000);
    eqsp32.pinMode(PIN_PANEL_TEMP, TIN);
    eqsp32.configTIN(PIN_PANEL_TEMP, 3988, 10000);

    eqsp32.pinMode(PIN_HEATER, RELAY);
    eqsp32.configRELAY(PIN_HEATER, 500, 1000);
    eqsp32.pinMode(PIN_PUMP, RELAY);
    eqsp32.configRELAY(PIN_PUMP, 500, 1000);

    createDisplay_Sensor(haBoilerTemp, 1, "°C", "mdi:thermometer", "temperature");
    createDisplay_Sensor(haPanelTemp,  1, "°C", "mdi:thermometer", "temperature");
    createDisplay_BinarySensor(haHeater,    "mdi:heating-coil");
    createDisplay_BinarySensor(haPump,      "mdi:water-pump");
    createDisplay_BinarySensor(haHeatLimit, "mdi:alert", "heat");
    createControl_Switch(haHeaterControl,   "mdi:toggle-switch");

    eqsp32.pinValue(PIN_HEATER, 0);
    eqsp32.pinValue(PIN_PUMP,   0);
    updateDisplay_Sensor(haBoilerTemp,         0);
    updateDisplay_Sensor(haPanelTemp,          0);
    updateDisplay_BinarySensor(haHeater,       false);
    updateDisplay_BinarySensor(haPump,         false);
    updateDisplay_BinarySensor(haHeatLimit,    false);
    updateControl_Switch(haHeaterControl,      false);
}

void loop() {
    float boilerTemp   = eqsp32.readPin(PIN_BOILER_TEMP) / 10.0;
    float panelTemp    = eqsp32.readPin(PIN_PANEL_TEMP)  / 10.0;
    bool  manualHeater = readControl_Switch(haHeaterControl);
    bool  pumpRunning  = (eqsp32.readPin(PIN_PUMP) > 0);

    // Circulation pump
    if (boilerTemp < BOILER_MAX_TEMP - HYSTERESIS) {
        if (panelTemp > boilerTemp + TEMP_DIFF_ON + HYSTERESIS) {
            eqsp32.pinValue(PIN_PUMP, 1000);
        } else if (panelTemp < boilerTemp + TEMP_DIFF_ON) {
            eqsp32.pinValue(PIN_PUMP, 0);
        }
    } else if (boilerTemp > BOILER_MAX_TEMP) {
        eqsp32.pinValue(PIN_PUMP, 0);
    }

    // Resistive heater (manual, pump-interlocked)
    if (manualHeater && !pumpRunning) {
        if (boilerTemp > HEATER_OFF_TEMP) {
            eqsp32.pinValue(PIN_HEATER, 0);
        } else if (boilerTemp < HEATER_ON_TEMP - HYSTERESIS) {
            eqsp32.pinValue(PIN_HEATER, 1000);
        }
    } else {
        eqsp32.pinValue(PIN_HEATER, 0);
    }

    // Update Home Assistant
    bool heatLimitActive =
        (manualHeater && !pumpRunning && boilerTemp > HEATER_OFF_TEMP) ||
        (boilerTemp > BOILER_MAX_TEMP &&
         panelTemp  > boilerTemp + TEMP_DIFF_ON + HYSTERESIS);

    updateDisplay_Sensor(haBoilerTemp,         boilerTemp);
    updateDisplay_Sensor(haPanelTemp,          panelTemp);
    updateDisplay_BinarySensor(haHeater,       eqsp32.readPin(PIN_HEATER) > 0);
    updateDisplay_BinarySensor(haPump,         eqsp32.readPin(PIN_PUMP)   > 0);
    updateDisplay_BinarySensor(haHeatLimit,    heatLimitActive);
}

Source code available for download. The complete project file, including EQConnect configuration and Home Assistant entity definitions, is available on the Erqos downloads page. The code compiles directly in the Arduino IDE with the EQSP32 library installed.

Why Not a Dedicated Solar Controller?

Dedicated differential solar controllers (the kind sold as plug-and-play accessories for solar thermal systems) cost between €20 and €80 and handle the basic differential temperature logic described in this article. For a simple single-collector, single-boiler installation, they do the job. The moment you need anything beyond that, you run into hard limits.

These controllers offer no external connectivity. There is no API, no MQTT, no way to read current temperatures, log historical data, or trigger the backup heater remotely. Alarm conditions (overtemperature, pump failure, sensor fault) activate a local buzzer or LED at best. If the controller fails silently, the only indication is that the boiler has gone cold.

They are also fixed-logic devices. The differential threshold, hysteresis, maximum temperature, and backup heater logic are set by a small rotary dial or a three-button menu with no persistence. If your system grows, you are locked out: you cannot add a second collector array, integrate a heat pump or solid fuel boiler as an alternative heat source, or adapt the control logic for seasonal or occupancy conditions.

Where EQSP32 changes the picture
The EQSP32 replaces the cheap controller at roughly the same wiring complexity, but turns the system into a supervised, remotely monitored installation. You get full temperature history in Home Assistant, remote backup heater activation from a phone, auditable sensor faults logged with timestamps, and the ability to add Modbus energy meters, flow sensors, or additional temperature probes without hardware changes. The control logic is source code, not a menu setting, so any future requirement is addressed by editing a file rather than replacing equipment.

For integrators sizing a project, the difference becomes clear in the service phase. A dedicated controller provides no remote diagnostics; a callback requires a site visit. An EQSP32-based installation exposes every sensor and relay state over MQTT. Faults are visible before the customer calls, and threshold adjustments can be pushed remotely without scheduling access to the plant room.

Why EQSP32 for This Application

Native NTC thermistor inputs

Temperature inputs are configured directly in firmware with B-value and nominal resistance. No external signal conditioning or ADC module required.

PWM relay derating

Relay outputs automatically apply a high pull-in duty cycle followed by a reduced hold duty cycle, keeping coil temperatures low during continuous operation.

Home Assistant auto-discovery

MQTT entities appear automatically in Home Assistant without manual configuration. Sensors, binary sensors, and control switches are declared in a single line each.

Autonomous local control

The control loop runs on the EQSP32 regardless of network state. Home Assistant connectivity is additive; the system does not depend on it to function safely.

Ready to build your solar water heater controller?

EQSP32 datasheet, MQTT integration guide, Home Assistant setup tutorial, and full project source code available.

View the EQSP32