Controlador para EcoSpa Ouro Fino ESPhome

Meu PC está bugando. Salvei postando para não perder. Jajá edito e concluo.

3 anos depois, eu finalmente tirei o projeto da prateleira e terminei, refinando o código com ajuda do Gemini.

Relembrando, o EcoSpa ouro fino, tem um painel simples originalmente. Funções: Hidro, e Aquecimento. Mostrador simples de temperatura. Se o aquecedor está ligado, e desliga-se a hidro, um timer de 10s continua rodando a hidro depois de desligar o aquecedor, para evitar superaquecimento da resistência.

Objetivo inicial: Mesma funcionalidade, porém com controle via Home Assistant.

Por engenharia reversa, e algumas ajudas de quem conhece mais que eu, consegui mapear as entradas e saídas do controle. Há uma caixa de potência que recebe 220vac, e converte para 12vdc e 5vdc. Esta placa de potencia recebe os comandos do painel, que realiza a logica de controle. Painel comanda o aquecimento e bomba por níveis lógicos e faz o controle de temperatura também.

Entradas:
VCC GPIO4 GPIO18 Marrom
Terra GPIO5 GPIO21 Laranja
Nível de água GPIO27 Vermelho
NTC GPIO23 Azul

Saídas:
Bomba Hidro GPIO16 Amarelo
Aquecedor GPIO17 Verde

Esses são os dados da minha banheira, mas a Ouro Fino é bem artesanal, então as cores de fios podem variar.
Com base nessas informações, criei um código na unha no ESPhome. Desconhecia o tipo do NTC, então medi a resistência do NTC em duas temperaturas diferentes 25c = 108.8k Ohm, 30c= 80k ohm.

Usei um SSD1306 que tinha em casa de um kit de eletrônicos que comprei a anos. 128x64pixels.

Queria um terceiro modo que é: quando a banheira chega na temperatura correta, a bomba da hidro desliga automaticamente, respeitando os 10 segundos de segurança. Re-ligando automaticamente quando a temperatura cai. Eu aumentei o tempo de segurança para 20s, porquê notei que sempre que desligava o aquecedor, a temperatura aumentava uns 2 ou 3 graus com apenas 10 segundos. Com 20, dá tempo da resistência esfriar completamente.

Além disso, queria um modo que a banheira pudesse continuar funcionando, mas que as teclas fisicas não atuassem. Tenho um filho de menos de 2 anos que adora apertar botôes, então acrescentei esse modo.

O código final foi refinado no Gemini, então use sabendo disso.

ESP32 opera em 3.3v, e o ATtiny opera em 5V, então usei optoacopladores nas entradas e saídas.
A minha caixa de potencia usa um transistor 78L05 para fornecer 5vcc, ele não aguenta tocar o ESP32, então tive que abrir a caixa e trocar o 78L05 por um 7805. Aparentemente algumas caixas de potência já vêm com o 7805. Vale a pena verificar qual é a sua.

O último problema foi, como deixar o projeto a prova d’agua. Usei botôes ip67 do ali express, e caixas de projeto ip67 também do ali express. Mas mesmo assim tive problema com água. Então resolvi fazer uma PCB sob medida, e separar os botões e tela, do microcontrolador e optoacopladores. O PCB fica dentro da banheira longe da água, e os botôes/tela ficam no lugar do controlador original.

Para acabamento visual, vou utilizar adesivo DTF colado no acrílico da caixa. Pensei em mandar fazer teclado membrana, mas é caro, então reaproveitei os botões do ali express, usei uma quantidade desrespeitosa de PU, e vamos ver o quanto aguenta.

Aproveitei para trocar o “controlador” dos leds da banheira, que também são isolados porcamente com uma membrana que se rompe e tudo oxida dentro. São só dois botões simples que originalmente ficam em uma PCB, usei os mesmos do controlador principal.

Abaixo ilustrações e código:


Primeiro protótipo de bancada funcionando, com um RpiPico como osciloscópio.



Primeira tentativa que teve problemas de ingressão de água.

Optoacopladores e resistores no perfboard.

Protoboard testando em bancada com potenciometro no lugar do NTC.


PCB com componentes exceto o devkit esp.



Caixa externa, versão 2.


Modelo do DTF do controlador.


PCB com tudo conectado.


Versão “final” ainda sem o DTF de acabamento. Vou deixar assim por alguns dias para monitorar ingresso de água.


Schematic do Kicad


PCB

Não consigo subir os arquivos do Kicad, mas quem quiser eu envio. Código do ESPhome está abaixo.

A disposição para dúvidas e sugestões.

esphome:
  name: ourofino
  friendly_name: ourofino
  min_version: 2025.11.0
  name_add_mac_suffix: false
  on_boot:
    - priority: 500
      then:
        - delay: 5s
    - priority: -100
      then:
        - script.execute: evaluate_outputs

esp32:
  variant: esp32
  framework:
    type: esp-idf

# Enable logging
logger:

# Enable Home Assistant API
api:

# Allow Over-The-Air updates
ota:
  - platform: esphome

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
globals:
  - id: system_state
    type: int
    initial_value: '1'
  - id: cooldown_time
    type: int
    initial_value: '0'
  - id: desired_heater
    type: bool
    initial_value: 'false'
  - id: desired_pump
    type: bool
    initial_value: 'false'
  - id: screen_locked
    type: bool
    initial_value: 'false'

switch:
  - platform: gpio
    id: pump_physical
    pin: GPIO16
    restore_mode: ALWAYS_OFF
    
  - platform: gpio
    id: heater_physical
    pin: GPIO18
    restore_mode: ALWAYS_OFF

  - platform: template
    id: heater_virtual
    name: "Heater Virtual"
    optimistic: true
    on_turn_on:
      - script.execute: evaluate_outputs
    on_turn_off:
      - script.execute: evaluate_outputs

script:
  - id: start_cooldown
    mode: restart
    then:
      - globals.set:
          id: cooldown_time
          value: '20'
      - while:
          condition:
            lambda: 'return id(cooldown_time) > 0;'
          then:
            - delay: 1s
            - lambda: |-
                id(cooldown_time) -= 1;
            - script.execute: evaluate_outputs
      - script.execute: evaluate_outputs

  - id: evaluate_outputs
    then:
      # Sync system_state to thermostat mode/preset (only if mismatch, e.g. from physical buttons)
      - lambda: |-
          climate::ClimateMode target_mode;
          std::string target_preset;
          if (id(system_state) == 1) { target_mode = climate::CLIMATE_MODE_OFF; target_preset = ""; }
          else if (id(system_state) == 2) { target_mode = climate::CLIMATE_MODE_OFF; target_preset = "Standby"; }
          else if (id(system_state) == 3) { target_mode = climate::CLIMATE_MODE_FAN_ONLY; target_preset = ""; }
          else if (id(system_state) == 4) { target_mode = climate::CLIMATE_MODE_HEAT; target_preset = "Normal"; }
          else { target_mode = climate::CLIMATE_MODE_HEAT; target_preset = "Eco"; }
          auto call = id(hottub).make_call();
          bool needs_update = false;
          if (id(hottub).mode != target_mode) { call.set_mode(target_mode); needs_update = true; }
          
          std::string current_preset = "";
          if (id(hottub).preset.has_value() && id(hottub).preset.value() == climate::CLIMATE_PRESET_ECO) {
            current_preset = "Eco";
          } else if (id(hottub).has_custom_preset()) {
            current_preset = id(hottub).get_custom_preset().c_str();
          }

          if (!target_preset.empty() && current_preset != target_preset) {
            call.set_preset(target_preset); needs_update = true;
          }
          if (needs_update) call.perform();

      # Step 1: Determine desired states
      # heater_virtual is controlled by the thermostat's heat_action/idle_action
      # It is only relevant when system is in a heating state (4 or 5)
      - lambda: |-
          id(desired_heater) = (id(heater_virtual).state && (id(system_state) == 4 || id(system_state) == 5));
          
      # Detect Heater turning OFF to trigger safety cooldown
      - if:
          condition:
            and:
              - switch.is_on: heater_physical
              - lambda: 'return !id(desired_heater);'
          then:
            - script.execute: start_cooldown
            - delay: 100ms 

      - lambda: |-
          // State 4 (Normal Heat): pump always on when heating active
          // State 5 (Eco Heat): pump only on when heater is actually running
          id(desired_pump) = (id(system_state) == 3 || id(system_state) == 4 || (id(system_state) == 5 && id(desired_heater)) || id(cooldown_time) > 0);
          
          // Safety override for water level
          if (!id(water_level_safe).state) {
            id(desired_pump) = false;
            id(desired_heater) = false;
          }
          

      # Apply Pump state
      - if:
          condition:
            lambda: 'return id(desired_pump);'
          then:
            - switch.turn_on: pump_physical
          else:
            - switch.turn_off: pump_physical
            
      # Apply Heater state (hardware interlock: heater never ON if pump OFF)
      - if:
          condition:
            and:
              - lambda: 'return id(desired_heater);'
              - switch.is_on: pump_physical
          then:
            - switch.turn_on: heater_physical
          else:
            - switch.turn_off: heater_physical
            
      # Apply Display Page based on state
      - if:
          condition:
            lambda: 'return id(system_state) == 1 && id(cooldown_time) == 0 && !id(screen_locked);'
          then:
            - display.page.show: page2
          else:
            - display.page.show: page1

binary_sensor:
  - platform: template
    name: "Pump Status"
    id: pump_status
    lambda: 'return id(pump_physical).state;'

  - platform: template
    name: "Heater Status"
    id: heater_status
    lambda: 'return id(heater_physical).state;'

  - platform: gpio
    pin: 
      number: GPIO23
      mode:
        input: true
        pullup: true
    name: "Water Level Safe"
    id: water_level_safe
    filters:
      - delayed_on_off: 3s
    on_state:
      - script.execute: evaluate_outputs

  - platform: gpio
    pin: 
      number: GPIO13
      mode:
        input: true
        pullup: true
    name: "Power"
    id: power
    filters:
      - delayed_on_off: 20ms
    on_press:
      - if:
          condition:
            lambda: 'return !id(screen_locked);'
          then:
            - lambda: |-
                if (id(system_state) == 1) {
                  id(system_state) = 2; // Power toggle from 1 to 2
                } else {
                  id(system_state) = 1;
                }
            - script.execute: evaluate_outputs

  - platform: gpio
    pin: 
      number: GPIO27
      mode:
        input: true
        pullup: true
    name: "Mode"
    id: mode_btn
    filters:
      - delayed_on_off: 20ms
    on_press:
      - if:
          condition:
            lambda: 'return !id(screen_locked);'
          then:
            - lambda: |-
                // Cycle through: Standby(2) -> Hydro(3) -> Normal Heat(4) -> Eco Heat(5) -> Standby(2)
                if (id(system_state) > 1 && id(system_state) < 5) {
                  id(system_state) += 1;
                } else if (id(system_state) == 5) {
                  id(system_state) = 2;
                }
            - script.execute: evaluate_outputs
        
  - platform: gpio
    pin: 
      number: GPIO25
      inverted: true
      mode:
        input: true
        pullup: true
    name: "UP"
    id: up
    filters:
      - delayed_on_off: 20ms
    on_press:      
      - delay: 100ms
      - if:
          condition:
            lambda: 'return !id(screen_locked) && !id(down).state;'
          then:
            - lambda: |-
                float new_target = id(hottub).target_temperature + 1;
                if (new_target <= 42) {
                  auto call = id(hottub).make_call();
                  call.set_target_temperature(new_target);
                  call.perform();
                }
            - delay: 500ms
            - while:
                condition:
                  and:
                    - binary_sensor.is_on: up
                    - lambda: 'return !id(screen_locked) && !id(down).state;'
                then:
                  - lambda: |-
                      float new_target = id(hottub).target_temperature + 1;
                      if (new_target <= 42) {
                        auto call = id(hottub).make_call();
                        call.set_target_temperature(new_target);
                        call.perform();
                      }
                  - delay: 150ms

  - platform: gpio
    pin: 
      number: GPIO14
      inverted: true
      mode:
        input: true
        pullup: true
    name: "DOWN"
    id: down
    filters:
      - delayed_on_off: 20ms
    on_press:
      - delay: 100ms
      - if:
          condition:
            lambda: 'return !id(screen_locked) && !id(up).state;'
          then:
            - lambda: |-
                float new_target = id(hottub).target_temperature - 1;
                if (new_target >= 20) {
                  auto call = id(hottub).make_call();
                  call.set_target_temperature(new_target);
                  call.perform();
                }
            - delay: 500ms
            - while:
                condition:
                  and:
                    - binary_sensor.is_on: down
                    - lambda: 'return !id(screen_locked) && !id(up).state;'
                then:
                  - lambda: |-
                      float new_target = id(hottub).target_temperature - 1;
                      if (new_target >= 20) {
                        auto call = id(hottub).make_call();
                        call.set_target_temperature(new_target);
                        call.perform();
                      }
                  - delay: 150ms

  - platform: template
    id: both_buttons_held
    name: "Both Buttons Held"
    lambda: 'return id(up).state && id(down).state;'
    filters:
      - delayed_on: 5s
    on_press:
      then:
        - lambda: |-
            id(screen_locked) = !id(screen_locked);
        - script.execute: evaluate_outputs

sensor:
  - platform: template
    name: "Cooldown Timer"
    id: cooldown_timer_sensor
    unit_of_measurement: "s"
    update_interval: 1s
    lambda: 'return id(cooldown_time);'

  - platform: resistance
    sensor: adc1
    configuration: DOWNSTREAM
    resistor: 100kOhm
    reference_voltage: 5V
    name: Resistance Sensor
    id: adc3
  
  - platform: adc
    pin: GPIO36
    name: "ADC"
    id: adc1
    internal: true
    update_interval: 0.5s
    attenuation: auto

  - platform: ntc
    sensor: adc3
    calibration:
      b_constant: 4066
      reference_temperature: 25°C
      reference_resistance: 100kOhm
    name: "Current Temperature"
    id: current_temp
    filters:
    - median:
        window_size: 11
        send_every: 4
        send_first_at: 1
    - sliding_window_moving_average:
        window_size: 5
        send_every: 1

climate:
  - platform: thermostat
    id: hottub
    name: "Hot Tub"
    sensor: current_temp
    min_idle_time: 10s
    min_heating_off_time: 10s
    min_heating_run_time: 10s
    min_fanning_off_time: 1s
    min_fanning_run_time: 1s
    heat_deadband: 0.5
    heat_overrun: 0.5
    visual:
      min_temperature: 20
      max_temperature: 42
      temperature_step: 1
    default_preset: Normal
    preset:
      - name: Standby
        default_target_temperature_low: 20
      - name: Normal
        default_target_temperature_low: 38
      - name: Eco
        default_target_temperature_low: 38
    idle_action:
      - switch.turn_off: heater_virtual
    heat_action:
      - switch.turn_on: heater_virtual
    # FAN_ONLY mode = Hydro (state 3): pump on, no heater
    fan_only_action:
      - switch.turn_off: heater_virtual
    on_control:
      # Sync system_state when HA changes mode or preset
      - lambda: |-
          climate::ClimateMode new_mode = x.get_mode().has_value() ? x.get_mode().value() : id(hottub).mode;
          
          std::string preset = "";
          if (x.get_preset().has_value()) {
            if (x.get_preset().value() == climate::CLIMATE_PRESET_ECO) preset = "Eco";
          } else if (x.has_custom_preset()) {
            preset = x.get_custom_preset().c_str();
          } else {
            if (id(hottub).preset.has_value() && id(hottub).preset.value() == climate::CLIMATE_PRESET_ECO) {
              preset = "Eco";
            } else if (id(hottub).has_custom_preset()) {
              preset = id(hottub).get_custom_preset().c_str();
            }
          }

          if (new_mode == climate::CLIMATE_MODE_OFF) {
            id(system_state) = (preset == "Standby") ? 2 : 1;
          }
          else if (new_mode == climate::CLIMATE_MODE_FAN_ONLY) {
            id(system_state) = 3;
          }
          else if (new_mode == climate::CLIMATE_MODE_HEAT) {
            id(system_state) = (preset == "Eco") ? 5 : 4;
          }
      - script.execute: evaluate_outputs

i2c:
  sda: GPIO21
  scl: GPIO22
  frequency: 400kHz

font:
  - file: "fonts/16020_FUTURAM.ttf"
    id: my_font_large
    size: 32
  - file: "fonts/16020_FUTURAM.ttf"
    id: my_font_medium
    size: 20
  - file: "fonts/16020_FUTURAM.ttf"
    id: my_font_small
    size: 15
  
display:
  - platform: ssd1306_i2c
    model: "SSD1306 128x64"
    id: oled
    address: 0x3C
    rotation: 0
    update_interval: 50ms
    pages:
      - id: page1
        lambda: |-
          // Top bar: Mode Indicator
          if (id(screen_locked)) {
            it.printf(2, 2, id(my_font_small), "Locked");
          } else if (id(system_state) == 1) {
            it.printf(2, 2, id(my_font_small), "Off");
          } else if (id(system_state) == 2) {
            it.printf(2, 2, id(my_font_small), "Standby");
          } else if (id(system_state) == 3) {
            it.printf(2, 2, id(my_font_small), "Hydro");
          } else if (id(system_state) == 4) {
            it.printf(2, 2, id(my_font_small), "Heat");
          } else if (id(system_state) == 5) {
            it.printf(2, 2, id(my_font_small), "EcoHeat");
          }

          // Top Right: Target Temp or Cooldown Timer
          if (id(cooldown_time) > 0) {
            it.printf(126, 2, id(my_font_small), TextAlign::TOP_RIGHT, "%ds", id(cooldown_time));
          } else {
            it.printf(126, 2, id(my_font_small), TextAlign::TOP_RIGHT, "%.0f°C", id(hottub).target_temperature);
          }

          // Large Current Temp Centered (Moved up to Y=16 to prevent overlap)
          if (!id(water_level_safe).state) {
            it.printf(64, 16, id(my_font_large), TextAlign::TOP_CENTER, "LOW WATER");
          } else {
            it.printf(64, 16, id(my_font_large), TextAlign::TOP_CENTER, "%.1f°C", id(current_temp).state);
          }

          // Bottom Bar Indicators
          if (id(heater_physical).state) {
            it.filled_circle(115, 58, 4);
            it.printf(108, 58, id(my_font_small), TextAlign::CENTER_RIGHT, "H");
          }
          if (id(pump_physical).state) {
            it.filled_circle(13, 58, 4);
            it.printf(20, 58, id(my_font_small), TextAlign::CENTER_LEFT, "P");
          }

          // Outer screen boundary
          it.rectangle(0, 0, 128, 64);
      - id: page2
        lambda: |-
          // Blank screen for State 1