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













