ESPhome Spa Contoller (thermostat)

Thought it was about time I shared my project and code to foster ideas and give people another example of what can be done with a handful of parts. Note that my project involves mains voltages so I won’t be showing how to wire it up. But in nutshell, I’m using opto-isolated relay (and this is a must with mains voltages) to switch the Spa pool old controller board’s (which is blown hence this project) safety relay at 12vdc from pin D0.

The rotary encoder allows the set point temperature to be changed on the fly and pressing it turns the pump off by opening the safety relay.

The screen has two pages, cycled every 5 seconds, one showing the thermostat info and the other showing the time and outside temperature from my Cumulus powered weather station by using MQTT

Currently pin D5 is unused but I’ve left it in the code.
Special thanks to youtuber 3ATIVE VFX Studio whos code was the basis for this (but wouldn’t compile for me so I stripped it back a bit) and ssieb, Jos and Jessie over on Discord! :clap: :clap:

The code below is now out of date and likely won't work anymore due to upgrades in ESPHome.
Please see the code in a later post for up to date and working code.

esphome:
  name: spa-thermostat

esp8266:
  board: d1_mini

# Enable logging
logger:
  level: DEBUG

# Enable Home Assistant API
api:

ota:
  password: "1f6ed1154bb09c5397e3979920ee71bc"

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "Spa-Thermostat Fallback Hotspot"
    password: "DzBH9uwq0q10"

captive_portal:

dallas:
  - pin: D3
    update_interval: 3s
    
i2c:   

time:
  - platform: homeassistant
    id: esptime

text_sensor:                      
  - platform: homeassistant
    name: "Weather_Station_Temp"
    id: "Weather_Station_Temp"
    entity_id: sensor.weather_station_temp
  
font:
  - file: "_fonts/refsan.ttf"
    id: my_font
    size: 32

display:
    platform: ssd1306_i2c
    model: SH1106 128x64
    id: my_display
    #reset_pin D0
    address: 0x3C
    rotation: 180°
    pages:
      - id: page1      
        lambda: |-
          if (id(temperature).has_state()) {
          it.printf(0, 0, id(my_font),"c:" "%.1f°", id(temperature).state);
          }
          it.printf(0, 31, id(my_font),"s:" "%.1f°", id(spa_thermostat).target_temperature_low);

      - id: page2      
        lambda: |-
          it.printf(0, 0, id(my_font), "o:%s", id(Weather_Station_Temp).state.c_str());
          it.strftime(0, 60, id(my_font), TextAlign::BASELINE_LEFT, "%H:%M", id(esptime).now());
         
interval:
  - interval: 5s
    then:
      - display.page.show_next: my_display
      - component.update: my_display           
          
switch:
  - platform: gpio
    pin: D0
    id: heater
    inverted: false
  - platform: gpio
    pin: D5
    id: pump
    inverted: false
    
binary_sensor:    
  - platform: gpio
    id: button
    pin:
      number: D4
      inverted: True
      mode: INPUT_PULLUP
    on_press:
      then:
        - if:
            condition:
               lambda: 'return id(spa_thermostat).mode == CLIMATE_MODE_HEAT;'
            then: 
              - logger.log: "Current state HA-switch is HEATING/IDLE -> switch to OFF"
              - climate.control: 
                  id: spa_thermostat
                  mode: "OFF"
            else: 
              - logger.log:  "Current state HA-switch is OFF -> switch to HEATING/IDLE"
              - climate.control: 
                  id: spa_thermostat
                  mode: "HEAT"    
web_server:
    port: 80

climate:
  - platform: thermostat
    name: "Spa Temperature Controller"
    id: spa_thermostat
    visual:
      min_temperature: 30 °C
      max_temperature: 45°C
      temperature_step: 0.1 °C
    sensor: temperature
    default_target_temperature_low: 38.2 °C
    min_heating_off_time: 300s
    min_heating_run_time: 120s
    min_idle_time: 300s
    heat_overrun: 0.1 °C
    heat_action:
      - switch.turn_on: heater    
    idle_action:
     - switch.turn_off: heater

sensor:
  - platform: dallas
    address: 0xb4000008f9101d28
    name: "temperature"
    id: temperature
  - platform: rotary_encoder
    id: encoder
    pin_a:
      number: D6
      mode: INPUT_PULLUP
    pin_b:
      number: D7
      mode: INPUT_PULLUP
    on_clockwise:
      - climate.control:
                      id: spa_thermostat
                      target_temperature: !lambda "return id(spa_thermostat).target_temperature_low + 0.1;"
    on_anticlockwise:
       - climate.control:
                      id: spa_thermostat
                      target_temperature: !lambda "return id(spa_thermostat).target_temperature_low - 0.1;"

Happy soaking! :slight_smile:

1 Like

Nice! Any chance of seeing some screen shots?

I’d love to do something like this with my spa, but the controller is much more complicated (more parts to control) and until it dies it’s hard to justify replacing it.

Here a short video showing the screen cycling through the two pages.

There are four relays safety, heat, low and high speed. This switches the safety relay (along with an manual override switch). The element has a SPST switch too. And the pump is switched high/low with a SPDT switch. All at 12vdc.

I will change it a little bit by adding the degrees sign on the outside temp.

1 Like

Below is working with Presets and has the ability to show the outside temperature via mqtt and Cumulus 3 software for home weather stations…

esphome:
  name: spa-thermostat

esp8266:
  board: d1_mini

# Enable logging
logger:
  level: DEBUG

# Enable Home Assistant API
api:

ota:
  password: "1f6ed1154bb09c5397e3979920ee71bc"

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "Spa-Thermostat Fallback Hotspot"
    password: "DzBH9uwq0q10"

captive_portal:

dallas:
  - pin: D3
    update_interval: 3s
    
i2c:   

time:
  - platform: homeassistant
    id: esptime

text_sensor:                      
  - platform: homeassistant
    name: "Weather_Station_Temp"
    id: "Weather_Station_Temp"
    entity_id: sensor.weather_station_temp
  
font:
  - file: "_fonts/refsan.ttf"
    id: my_font
    size: 31

display:
    platform: ssd1306_i2c
    model: SH1106 128x64
    id: my_display
    #reset_pin D0
    address: 0x3C
    rotation: 180°
    pages:
      - id: page1      
        lambda: |-
          if (id(temperature).has_state()) {
          it.printf(0, 0, id(my_font),"C:" "%.1f°", id(temperature).state);
          }
          it.printf(0, 31, id(my_font),"S:" "%.1f°", id(spa_thermostat).target_temperature_low);

      - id: page2      
        lambda: |-
          it.strftime(0, 0, id(my_font), "T:%I:%M", id(esptime).now());        
          it.printf(0, 31, id(my_font), "O:" "%s", id(Weather_Station_Temp).state.c_str());;

interval:
  - interval: 5s
    then:
      - display.page.show_next: my_display
      - component.update: my_display           
          
switch:
  - platform: gpio
    pin: D0
    id: heater
    inverted: false
  - platform: gpio
    pin: D5
    id: pump
    inverted: false
    
binary_sensor:    
  - platform: gpio
    id: button
    pin:
      number: D4
      inverted: True
      mode: INPUT_PULLUP
    on_press:
      then:
        - if:
            condition:
               lambda: 'return id(spa_thermostat).mode == CLIMATE_MODE_HEAT;'
            then: 
              - logger.log: "Current state HA-switch is HEATING/IDLE -> switch to OFF"
              - climate.control: 
                  id: spa_thermostat
                  mode: "OFF"
            else: 
              - logger.log:  "Current state HA-switch is OFF -> switch to HEATING/IDLE"
              - climate.control: 
                  id: spa_thermostat
                  mode: "HEAT"    
web_server:
    port: 80

climate:
  - platform: thermostat
    name: "Spa Temperature Controller"
    id: spa_thermostat
    default_preset: "target temp low"
    preset:
    -  name: "target temp low"
       default_target_temperature_low: 39.6
    on_boot_restore_from: default_preset   
    visual:
      min_temperature: 30 
      max_temperature: 45
      temperature_step: 0.1 
    sensor: temperature
    min_heating_off_time: 300s
    min_heating_run_time: 120s
    min_idle_time: 300s
    heat_overrun: 0.1 
    heat_action:
      - switch.turn_on: heater    
    idle_action:
     - switch.turn_off: heater

sensor:
  - platform: dallas
    address: 0xb4000008f9101d28
    name: "temperature"
    id: temperature
  - platform: rotary_encoder
    id: encoder
    pin_a:
      number: D6
      mode: INPUT_PULLUP
    pin_b:
      number: D7
      mode: INPUT_PULLUP
    on_clockwise:
      - climate.control:
                      id: spa_thermostat
                      target_temperature: !lambda "return id(spa_thermostat).target_temperature_low + 0.1;"
    on_anticlockwise:
       - climate.control:
                      id: spa_thermostat
                      target_temperature: !lambda "return id(spa_thermostat).target_temperature_low - 0.1;"