Created
June 5, 2026 07:27
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
commented
Jun 5, 2026
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment