Skip to content

Instantly share code, notes, and snippets.

@vxav
Last active April 25, 2025 07:40
Show Gist options
  • Select an option

  • Save vxav/b0b57b0fcec4bde834ae5a3f3c863c44 to your computer and use it in GitHub Desktop.

Select an option

Save vxav/b0b57b0fcec4bde834ae5a3f3c863c44 to your computer and use it in GitHub Desktop.

Bill of materials

  • ESP8266 - 2.32€: Controller
  • ADS1115 - 1,92€: ADC board to have more analog input and better resolution.
  • pH Sensor and circuit board:
  • pH buffer 4 - 4,50€ - Much cheaper in growshops than pool shops.
  • pH buffer 7 - 4,50€ - Much cheaper in growshops than pool shops.
  • I power my ESP with a USB phone charger.

Optional:

Diagram

You can download the diagram below in PNG and open it in Excalidraw to edit it.

If you are just doing pH readings in HA, you can skip the screen, switch and pressure sensor.

image

ESPHome configuration

substitutions:
  device_name: esppool32-controller
  base_id: "esppool32"
  name: "Pool Controller"

esphome:
  name: $device_name
  comment: "Pool automation controller"

esp32:
  board: nodemcu-32s

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  reboot_timeout: 0s

# Enable logging
logger:

# Enable Home Assistant API
api:
  reboot_timeout: 0s
  encryption:
    key: !secret api_encryption_key

# Enable over-the-air updates
ota:
  - platform: esphome
    password: !secret ota_password

preferences:
  flash_write_interval: 5s

captive_portal:

i2c:
  sda: GPIO21
  scl: GPIO22
  scan: True
  frequency: 800kHz

ads1115:
  - address: 0x48

button:
  - platform: restart
    name: "Controller Restart"

font:
  ### Screen font
  - file: "gfonts://Open+Sans"
    id: arial_unicode
    size: 18

display:
  ### Oled display screen
  - platform: ssd1306_i2c
    model: "SSD1306 128x64"
    id: "${base_id}_display_screen"
    # reset_pin: D0
    address: 0x3C
    lambda: |-
      if (!id(${base_id}_display_switch).state) {
        it.printf(0, 0, id(arial_unicode), "Filter: %.1f psi", id(${base_id}_filter_pressure).state);
        it.printf(0, 18, id(arial_unicode), "Temp: %.1f °C", id(pool_temperature).state);
        it.printf(0, 36, id(arial_unicode), "Ph: %.2f", id(${base_id}_ph).state);
      }

binary_sensor:
  ### Toggle switch to turn on the screen.
  - platform: gpio
    pin:
      number: GPIO18
      mode:
        input: true
        pullup: true
    id: "${base_id}_display_switch"

switch:
  ### VFD - Input for Frequency - input 1 and 3
  ### Relay #1
  - platform: gpio
    id: "${base_id}_relay_frequency_1"
    pin: GPIO13
    name: "Frequency input 1"
    internal: true
    inverted: true
    restore_mode: RESTORE_DEFAULT_OFF
  
  ### Relay #2
  - platform: gpio
    id: "${base_id}_relay_frequency_3"
    pin: GPIO25
    name: "Frequency input 3"
    internal: true
    inverted: true
    restore_mode: RESTORE_DEFAULT_OFF
    
  ### VFD - Input for Start
  ### Relay #3
  - platform: gpio
    icon: "mdi:pump"
    id: "${base_id}_relay_run"
    pin: GPIO26
    name: "Start Pump"
    inverted: true
    restore_mode: RESTORE_DEFAULT_OFF
    on_turn_on:
      then:
        - lambda: |-
            id(${base_id}_last_vfd_speed) = id(${base_id}_pool_pump_speed).state.c_str();
        - if:
            condition:
              lambda: 'return id(${base_id}_pool_pump_speed).state != "100%";'
            then:
              - select.set:
                  id: "${base_id}_pool_pump_speed"
                  option: "100%"
              - delay: !lambda "return id(pump_delay) * 1000;"  # Delay in milliseconds
              - lambda: |-
                  id(${base_id}_pool_pump_speed).publish_state(id(${base_id}_last_vfd_speed).c_str());

  ### Controls contactor for chlorine pump
  - platform: gpio
    icon: "mdi:blood-bag"
    id: "${base_id}_relay_chlorine_pump"
    pin: GPIO27
    name: "Chlorine pump"
    interlock: ["${base_id}_relay_ph_pump"]
    inverted: false
    restore_mode: RESTORE_DEFAULT_OFF

  ### Controls contactor for pH pump
  - platform: gpio
    icon: "mdi:blood-bag"
    id: "${base_id}_relay_ph_pump"
    pin: GPIO32
    name: "pH pump"
    interlock: ["${base_id}_relay_chlorine_pump"]
    inverted: false
    restore_mode: RESTORE_DEFAULT_OFF

select:
  ### VFD - Speed options
  - platform: template
    name: "Pump Speed"
    icon: "mdi:speedometer"
    id: "${base_id}_pool_pump_speed"
    optimistic: true
    options:
      - "100%"
      - "90%"
      - "60%"
      - "50%"
    initial_option: "100%"
    restore_value: true
    on_value:
      then:
        - lambda: |-
            if (id(${base_id}_pool_pump_speed).state == "100%") {
              id(${base_id}_relay_frequency_1).turn_off();
              id(${base_id}_relay_frequency_3).turn_off();
            } else if (id(${base_id}_pool_pump_speed).state == "90%") {
              id(${base_id}_relay_frequency_1).turn_on();
              id(${base_id}_relay_frequency_3).turn_off();
            } else if (id(${base_id}_pool_pump_speed).state == "60%") {
              id(${base_id}_relay_frequency_1).turn_off();
              id(${base_id}_relay_frequency_3).turn_on();
            } else if (id(${base_id}_pool_pump_speed).state == "50%") {
              id(${base_id}_relay_frequency_1).turn_on();
              id(${base_id}_relay_frequency_3).turn_on();
            }

number:
  ### pH 7 Calibration voltage
  - platform: template
    name: "pH 7 calibration"
    id: "${base_id}_ph7_calibration"
    icon: "mdi:tune-vertical-variant"
    unit_of_measurement: "V"
    mode: box
    min_value: 0
    max_value: 4.5
    step: 0.001
    restore_value: true
    optimistic: true
    on_value:
      then:
        - lambda: |-
            id(${base_id}_ph7_calibration_var) = float(x);

  ### pH 4 Calibration voltage
  - platform: template
    name: "pH 4 calibration"
    id: "${base_id}_ph4_calibration"
    icon: "mdi:tune-vertical-variant"
    unit_of_measurement: "V"
    mode: box
    min_value: 0
    max_value: 4.5
    step: 0.001
    restore_value: true
    optimistic: true
    on_value:
      then:
        - lambda: |-
            id(${base_id}_ph4_calibration_var) = float(x);

  ### Time to run at full speed on startup
  - platform: template
    name: "Pump Full Speed Startup Delay"
    id: "${base_id}_pump_full_start_delay"
    icon: "mdi:timer"
    unit_of_measurement: "s"
    mode: box
    min_value: 5
    max_value: 300
    step: 5
    initial_value: 40
    restore_value: true
    optimistic: true
    on_value:
      then:
        - lambda: |-
            id(pump_delay) = (int)x;

globals:
  ### pH 7 Calibration number variable
  - id: "${base_id}_ph7_calibration_var"
    type: float
    restore_value: no
    initial_value: '0'

  ### pH 4 Calibration number variable
  - id: "${base_id}_ph4_calibration_var"
    type: float
    restore_value: no
    initial_value: '0'
  
  ### Store last VFD speed value
  - id: "${base_id}_last_vfd_speed"
    type: std::string
    restore_value: no

  ### Time to run at full speed on startup
  - id: pump_delay
    type: int
    restore_value: yes
    initial_value: '40'

sensor:
  ### Filter pressure in PSI
  - platform: ads1115
    multiplexer: 'A0_GND'
    gain: 6.144
    name: "Filter Pressure"
    id: "${base_id}_filter_pressure"
    unit_of_measurement: "psi"
    update_interval: 2s
    icon: "mdi:propane-tank"
    accuracy_decimals: 1
    filters:
    - calibrate_linear:
      - 0.495 -> 0
      - 4.5 -> 72
    - median:
        window_size: 5
        send_every: 5
    - clamp:
        min_value: 0

  ### PH in generic units
  - platform: ads1115
    multiplexer: 'A1_GND'
    gain: 6.144
    name: "pH"
    id: "${base_id}_ph"
    unit_of_measurement: ""
    update_interval: 5s
    icon: "mdi:ph"
    accuracy_decimals: 1
    filters:
    - lambda: |-
        float m = (7.0 - 4.0)/(id(${base_id}_ph7_calibration_var) - id(${base_id}_ph4_calibration_var));
        float c = 7.0 - (m * id(${base_id}_ph7_calibration_var));
        return (m * x) + c;
    - median:
        window_size: 3
        send_every: 3

  ### Filter pressure in Volts for calibration
  - platform: ads1115
    multiplexer: 'A0_GND'
    gain: 6.144
    name: "Filter Pressure Voltage"
    id: "${base_id}_filter_pressure_volts"
    disabled_by_default: true
    unit_of_measurement: "V"
    icon: "mdi:current-dc"
    update_interval: 60s

  ### PH in Volts for calibration
  - platform: ads1115
    multiplexer: 'A1_GND'
    gain: 6.144
    name: "pH Voltage"
    id: "${base_id}_ph_volts"
    unit_of_measurement: ""
    icon: "mdi:current-dc"
    update_interval: 2s
    accuracy_decimals: 3
  
  ### Water temperature taken from HA
  - platform: homeassistant
    id: pool_temperature
    entity_id: sensor.pool_thermometer_temperature

HA Gauge card

The color will change according to how suitable the pH is.

You can change the thresholds if you use bromine as it tolerates wider pH variations.

image
cards:
  - type: gauge
    entity: sensor.ph
    min: 6.5
    max: 8
    needle: false
    segments:
      - from: 0
        color: red
      - from: 6.8
        color: yellow
      - from: 7
        color: lightgreen
      - from: 7.2
        color: green
      - from: 7.5
        color: lightgreen
      - from: 7.7
        color: yellow
      - from: 7.9
        color: redred

Calibration

image
  • Open the Pool-Controller device in HA.
  • Dip the probe in deionized water until the voltage is stable.
  • Take it out, shake the water off and dip it in buffer 7.
  • When the voltage is stable, change the value of pH 7 calibration.
  • Take it out, shake the water off and dip it in deionized water.
  • Take it out, shake the water off and dip it in buffer 4.
  • When the voltage is stable, change the value pH 4 calibration.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment