Skip to content

Instantly share code, notes, and snippets.

@alepee
Created June 5, 2026 07:27
Show Gist options
  • Select an option

  • Save alepee/45c46b4bab72e654982436ce14f2a982 to your computer and use it in GitHub Desktop.

Select an option

Save alepee/45c46b4bab72e654982436ce14f2a982 to your computer and use it in GitHub Desktop.
ESPHome pool filtration manager (Sonoff THR316D + WTS01) — auto-computed filtration schedule from pool volume, pump flow, treatment type and water temperature
substitutions:
friendly_name: "Pool"
device_name: pool
esphome:
name: $device_name
friendly_name: $friendly_name
name_add_mac_suffix: false
project:
name: alepee.esphome-sonoff-thr316d-pool-manager
version: "1.0"
on_boot:
- priority: 90
then:
- logger.log: "Device booting - initializing components"
# Make sure the sensor power is on at startup
- switch.turn_on: ${device_name}_sensor_power
# Make sure the relay is in a known state at startup
- switch.turn_off: relay
# winterize_mode persists across reboots (its restore_mode handles it) so
# freeze protection survives a power blip; sync its LED to the restored state.
- if:
condition:
switch.is_on: winterize_mode
then:
- switch.turn_on: auto_led
else:
- switch.turn_off: auto_led
- switch.turn_off: switch_led
- logger.log: "Initialization complete"
esp32:
board: nodemcu-32s
framework:
type: esp-idf
# Enable logging
logger:
level: DEBUG
logs:
sensor: INFO
switch: INFO
binary_sensor: INFO
uart: DEBUG
display: INFO
# Enable Home Assistant API
api:
encryption:
key: !secret pool_api_key
ota:
- platform: esphome
password: !secret pool_ota_password
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
ap:
ssid: $device_name
captive_portal:
web_server:
port: 80
version: 3
# Setup UART bus for WTS01 Sensor
uart:
rx_pin: GPIO25
baud_rate: 9600
# Display for temperature readings
display:
platform: tm1621
id: tm1621_display
cs_pin: GPIO17
data_pin: GPIO5
read_pin: GPIO23
write_pin: GPIO18
lambda: |-
if (!isnan(id(water_temp).state)) {
it.printf(0, "%.1f", id(water_temp).state);
it.display_celsius(true);
}
it.printf(1, "%d", static_cast<int>(id(daily_pump_runtime).state));
# float total_minutes = id(daily_pump_runtime).state;
# int hours = static_cast<int>(total_minutes / 60);
# int minutes_part = static_cast<int>(fmod(total_minutes, 60.0));
# it.printf(1, "%d.%02d", hours, minutes_part);
time:
- platform: sntp
id: sntp_time
timezone: Europe/Paris
- platform: homeassistant
id: homeassistant_time
on_time:
- seconds: 0
minutes: 0
hours: 0
then:
- lambda: |-
// Close out any in-progress run into the running total before
// zeroing the day, and restart the on-time marker at midnight so
// the new day counts only post-midnight runtime (otherwise the
// pre-midnight portion of an overnight run is counted twice).
if (id(last_pump_on_time) > 0) {
float now_s = millis() / 1000;
float pre_midnight = (now_s - id(last_pump_on_time)) / 60.0;
if (pre_midnight < 0) pre_midnight = 0; // millis() wrap guard
id(total_pump_runtime) += pre_midnight;
id(last_pump_on_time) = now_s;
}
id(pump_runtime_today) = 0;
- sensor.template.publish:
id: daily_pump_runtime
state: 0
# Global variables for tracking pump runtime
globals:
# millis()/1000 marker of when the pump turned on (0 = off). Not persisted,
# so runtime in progress at a reboot is not added to the totals (acceptable
# under-count; an OTA/power blip mid-run loses that segment).
- id: last_pump_on_time
type: int
restore_value: no
initial_value: "0"
- id: pump_runtime_today
type: float
restore_value: yes
initial_value: "0"
- id: total_pump_runtime
type: float
restore_value: yes
initial_value: "0"
# Last water temperature considered representative of the basin: latched only
# after the pump has circulated for SETTLE_SECONDS (probe is in the circuit, not
# the basin). Seeded at 20 C so the scheduler can bootstrap before the first run.
- id: last_valid_water_temp
type: float
restore_value: yes
initial_value: "20.0"
# Winterize cycle: run the pump to prevent freezing, then pause.
# mode: single ignores re-triggers while a cycle is running, which replaces
# the old manual "winterize_timer_running" guard global.
script:
- id: winterize_cycle
mode: single
then:
- logger.log:
format: "Winterize mode: water %.1f C below threshold - running pump"
args: ['id(water_temp).state']
- switch.turn_on: relay
# 10 min when below 0 C, 5 min when below 3 C
- delay: !lambda "return id(water_temp).state < 0.0 ? 600000 : 300000;"
- switch.turn_off: relay
- delay: 30min
# Recompute the recommended daily filtration time from the pool parameters and the
# latched reference pool temperature. max(biological demand, hydraulic turnover
# floor), clamped. Salt uses a higher turnover target than chlorine.
- id: recompute_recommended
then:
- lambda: |-
float t = id(last_valid_water_temp); // latched pool temp, seeded 20 C
if (isnan(t)) return;
float demand = t / 2.0; // temp/2 rule
float target = (id(treatment_type).current_option() == "Sel") ? 2.0 : 1.5;
float turnover = id(pool_volume).state * target / id(pump_flow).state;
float rec = demand > turnover ? demand : turnover;
if (rec < 3.0) rec = 3.0; // MIN_HOURS
if (rec > 18.0) rec = 18.0; // MAX_HOURS (solar 10h + night 8h)
id(recommended_filtration_time).publish_state(rec);
// Build the human-readable schedule, mirroring the scheduler: fill the
// solar window first (centred on 14:00, capped at 10h = 09:00-19:00),
// overflow the remainder into the off-peak night window from 23:00.
auto fmt = [](float h) -> std::string {
h = fmodf(h, 24.0f); // wrap past midnight
if (h < 0.0f) h += 24.0f; // and before midnight
int hh = (int) h;
int mm = (int) roundf((h - hh) * 60.0f);
if (mm == 60) { hh = (hh + 1) % 24; mm = 0; }
char buf[6];
snprintf(buf, sizeof(buf), "%02d:%02d", hh, mm);
return std::string(buf);
};
const float SOLAR_MID = 14.0, SOLAR_W = 10.0, NIGHT_MID = 3.0;
float solar_h = rec < SOLAR_W ? rec : SOLAR_W;
float night_h = rec - solar_h;
std::string sched = fmt(SOLAR_MID - solar_h / 2.0f) + "-" + fmt(SOLAR_MID + solar_h / 2.0f);
if (night_h > 0.0f) {
sched += ", " + fmt(NIGHT_MID - night_h / 2.0f) + "-" + fmt(NIGHT_MID + night_h / 2.0f);
}
id(filtration_schedule).publish_state(sched);
sensor:
- platform: wifi_signal
name: Wifi RSSI
update_interval: 60s
- platform: uptime
id: uptime_sensor
internal: True
on_raw_value:
then:
- text_sensor.template.publish:
id: uptime_human
state: !lambda |-
int seconds = round(id(uptime_sensor).get_raw_state());
int days = seconds / (24 * 3600);
seconds = seconds % (24 * 3600);
int hours = seconds / 3600;
seconds = seconds % 3600;
int minutes = seconds / 60;
return (
(days ? to_string(days) + "d " : "") +
(hours ? to_string(hours) + "h " : "") +
(minutes ? to_string(minutes) + "m" : "0m")
).c_str();
- platform: internal_temperature
name: "Internal Temp"
entity_category: diagnostic
id: esp32_temp
icon: mdi:chip
device_class: temperature
state_class: measurement
unit_of_measurement: °C
# WTS01 temperature sensor
- platform: wts01
name: Pool Water Temperature
id: water_temp
filters:
- throttle_average: 60s
accuracy_decimals: 1
on_value:
then:
- lambda: |-
// Probe is in the hydraulic circuit: trust the reading as basin
// temperature only after the pump has run >= SETTLE_SECONDS (180 s).
if (id(last_pump_on_time) > 0) {
float on_secs = (millis() / 1000) - id(last_pump_on_time);
if (on_secs < 0) on_secs = 0; // millis() wrap guard
if (on_secs >= 180) {
id(last_valid_water_temp) = id(water_temp).state;
}
}
- script.execute: recompute_recommended
- if:
condition:
and:
- switch.is_on: winterize_mode
- lambda: "return id(water_temp).state < 3.0;"
then:
- script.execute: winterize_cycle
# Total pump runtime
- platform: template
name: Total pump runtime
id: total_pump_runtime_sensor
accuracy_decimals: 0
icon: "mdi:timer"
device_class: duration
state_class: total_increasing
unit_of_measurement: min
# Daily pump runtime
- platform: template
name: Daily pump runtime
id: daily_pump_runtime
accuracy_decimals: 0
icon: "mdi:timer-outline"
device_class: duration
state_class: total_increasing
unit_of_measurement: min
# Recommended daily filtration time, recomputed by the recompute_recommended script
- platform: template
name: Recommended filtration time
id: recommended_filtration_time
accuracy_decimals: 1
unit_of_measurement: h
device_class: duration
state_class: measurement
icon: "mdi:timer-cog"
# Inferred basin temperature: the last reading latched while the pump was
# circulating (circuit temp = basin temp during circulation). Unlike the raw
# "Pool Water Temperature" (circuit probe), this stays meaningful when the pump
# is off, holding the last reliable value.
- platform: template
name: Pool basin temperature
id: pool_basin_temp
accuracy_decimals: 1
device_class: temperature
state_class: measurement
unit_of_measurement: "°C"
update_interval: 60s
lambda: "return id(last_valid_water_temp);"
icon: "mdi:pool-thermometer"
number:
- platform: template
name: Pool volume
id: pool_volume
optimistic: true
restore_value: true
initial_value: 50
min_value: 1
max_value: 200
step: 1
unit_of_measurement: "m³"
mode: box
entity_category: config
icon: "mdi:pool"
on_value:
- script.execute: recompute_recommended
- platform: template
name: Pump flow rate
id: pump_flow
optimistic: true
restore_value: true
initial_value: 12
min_value: 1
max_value: 50
step: 0.5
unit_of_measurement: "m³/h"
mode: box
entity_category: config
icon: "mdi:pump"
on_value:
- script.execute: recompute_recommended
select:
- platform: template
name: Treatment type
id: treatment_type
optimistic: true
restore_value: true
options:
- Chlore
- Sel
initial_option: Chlore
entity_category: config
icon: "mdi:flask"
on_value:
- script.execute: recompute_recommended
binary_sensor:
- platform: gpio
pin: GPIO00
id: reset
internal: true
filters:
- invert:
- delayed_off: 10ms
on_click:
- min_length: 50ms
max_length: 999ms
then:
- switch.toggle: relay
- logger.log: "Button clicked - toggling pump"
- min_length: 1000ms
max_length: 9999ms
then:
- switch.toggle: winterize_mode
- if:
condition:
switch.is_on: winterize_mode
then:
- logger.log: "Winterize mode ACTIVATED"
# Flash auto_led 3 times when winterize mode is activated
- switch.turn_on: auto_led
- delay: 200ms
- switch.turn_off: auto_led
- delay: 200ms
- switch.turn_on: auto_led
- delay: 200ms
- switch.turn_off: auto_led
- delay: 200ms
- switch.turn_on: auto_led
else:
- logger.log: "Winterize mode DEACTIVATED"
- switch.turn_off: auto_led
switch:
- platform: gpio
name: Pump
pin: GPIO21
id: relay
restore_mode: RESTORE_DEFAULT_OFF
on_turn_on:
- globals.set:
id: last_pump_on_time
value: !lambda "return millis() / 1000;"
- logger.log: "Pump turned ON"
- delay: 500ms
- switch.turn_on: switch_led
on_turn_off:
- lambda: |-
if (id(last_pump_on_time) > 0) {
float runtime_seconds = (millis() / 1000) - id(last_pump_on_time);
if (runtime_seconds < 0) runtime_seconds = 0; // millis() wrap guard
float runtime_minutes = runtime_seconds / 60.0;
id(total_pump_runtime) += runtime_minutes;
id(pump_runtime_today) += runtime_minutes;
id(last_pump_on_time) = 0;
}
- logger.log: "Pump turned OFF"
- delay: 500ms
- switch.turn_off: switch_led
# Winterize mode switch
- platform: template
name: Winterize Mode
id: winterize_mode
optimistic: true
restore_mode: RESTORE_DEFAULT_OFF
on_turn_on:
- switch.turn_on: auto_led
on_turn_off:
- switch.turn_off: auto_led
# Enables the on-device filtration scheduler. OFF = hand control to the
# physical button / Home Assistant / an external (e.g. PV-surplus) controller.
- platform: template
name: Auto filtration control
id: auto_filtration
optimistic: true
restore_mode: RESTORE_DEFAULT_OFF
icon: "mdi:autorenew"
# Rightmost (green) LED for winterize mode indicator
- platform: gpio
id: auto_led
internal: true
pin:
number: GPIO13
inverted: true
# Leftmost (red) LED for pump status indicator
- platform: gpio
id: switch_led
internal: true
pin:
number: GPIO16
inverted: true
# This is needed to power the external sensor.
# It receives 3v3 from this pin, which is pulled up on boot.
- platform: gpio
pin: GPIO27
id: ${device_name}_sensor_power
restore_mode: ALWAYS_ON
on_turn_on:
then:
- logger.log: "${device_name}_sensor_power turned ON"
on_turn_off:
then:
- logger.log: "${device_name}_sensor_power turned OFF"
text_sensor:
# Human-readable filtration on-slots for the day, published by recompute_recommended.
# Always shown (the plan the auto scheduler would follow), even when auto is off.
- platform: template
name: Filtration schedule
id: filtration_schedule
icon: "mdi:calendar-clock"
- platform: template
name: Uptime
id: uptime_human
icon: mdi:clock-start
entity_category: diagnostic
disabled_by_default: True
- platform: wifi_info
ip_address:
name: IP
ssid:
name: SSID
bssid:
name: BSSID
# The middle (blue) LED is used as wifi status indicator
status_led:
pin:
number: GPIO15
inverted: true
button:
- platform: restart
name: Restart
entity_category: ""
# Publish live runtime every 5s so the display and HA reflect the current run
interval:
- interval: 5s
then:
- lambda: |-
if (id(last_pump_on_time) > 0) {
float runtime_seconds = (millis() / 1000) - id(last_pump_on_time);
if (runtime_seconds < 0) runtime_seconds = 0; // millis() wrap guard
float runtime_minutes = runtime_seconds / 60.0;
float daily = id(pump_runtime_today) + runtime_minutes;
float total = id(total_pump_runtime) + runtime_minutes;
id(daily_pump_runtime).publish_state(daily);
id(total_pump_runtime_sensor).publish_state(total);
} else {
id(daily_pump_runtime).publish_state(id(pump_runtime_today));
id(total_pump_runtime_sensor).publish_state(id(total_pump_runtime));
}
# Filtration scheduler: fill the solar window first (centred on 14:00, capped at
# 10h = 09:00-19:00) to soak up PV, then overflow the remainder into the off-peak
# night window 23:00-07:00. Runs only when auto_filtration is ON and winterize_mode
# is OFF; when auto_filtration is OFF the relay is left untouched so the button / HA
# / an external PV controller can drive it.
- interval: 60s
id: filtration_scheduler
then:
- if:
condition:
and:
- switch.is_on: auto_filtration
- switch.is_off: winterize_mode
- lambda: "return id(sntp_time).now().is_valid();"
then:
- lambda: |-
auto now = id(sntp_time).now();
float now_h = now.hour + now.minute / 60.0;
float H = id(recommended_filtration_time).state;
if (isnan(H)) return; // no recommendation yet
const float SOLAR_MID = 14.0, SOLAR_W = 10.0;
const float NIGHT_MID = 3.0;
float solar_h = H < SOLAR_W ? H : SOLAR_W;
float night_h = H - solar_h;
bool should_run = false;
// Solar block, centred on 14:00
if (solar_h > 0.0f) {
float ss = SOLAR_MID - solar_h / 2.0f;
float se = SOLAR_MID + solar_h / 2.0f;
if (now_h >= ss && now_h < se) should_run = true;
}
// Night overflow block, centred on 03:00, wrapping past midnight
if (night_h > 0.0f) {
float ns = NIGHT_MID - night_h / 2.0f; // may go negative (before midnight)
float ne = NIGHT_MID + night_h / 2.0f;
if (ns >= 0.0f) {
if (now_h >= ns && now_h < ne) should_run = true;
} else {
if (now_h >= ns + 24.0f || now_h < ne) should_run = true;
}
}
if (should_run && !id(relay).state) {
id(relay).turn_on();
} else if (!should_run && id(relay).state) {
id(relay).turn_off();
}
@alepee

alepee commented Jun 5, 2026

Copy link
Copy Markdown
Author
image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment