Water Softener Salt Level with EZsalt

I’ve seen other questions and some solutions to this but I thought I’d share my solution to this.

I bought the EZsalt product originally but found the software was not very good and did not integrate with HA at all. I then realized that it uses a standard ESP8266 and VL53L0X time of flight distance sensor. So I made a EspHome config and flashed it to the device and it works great. The EZsalt hardware is actually pretty nice (well designed enclosure) and with EspHome & HA is is way bettter. In HA I use a gauge card for display.

esphome:
  name: softener1
  platform: ESP8266
  board: d1_mini

wifi:
  ssid: "***"
  password: "***"

captive_portal:

logger:

api:

ota:

i2c:
  sda: D6
  scl: D7
  scan: True

sensor:
  - platform: vl53l0x
    name: softener1_salt_level
    update_interval: 10s
    address: 0x29
    unit_of_measurement: "Inches"
    filters:
      - multiply: 39.37  # convert from mm to inches
      - offset: -35      # height of salt container
      - multiply: -1     # back to a positive value

Screenshot 2024-04-15 084343

1 Like

Wow that thing is pricey if it’s just an ESP and ToF sensor. I don’t know if a nice case is worth $130.

1 Like

i agree, but i priced out making a custom pcb with the components and the printing & build of the enclosure and realized that it’s just easier to pay for the ezsalt hardware. spending $100 won’t change my life and the hardware build is actually really nice.

@weswitt Thank you for sharing! While I like their build, the price is too high… but from your post I see they used a VL53L0X time of flight distance sensor which I was unaware of! I made a very cheap sensor using an ESP and an ultrasonic sensor. The only drawback is that the sensor cannot be fully enclosed in its case so it is getting a bit corroded but it is just a few dollars and it has been running for at least 2 years. The other issue, which I could possibly mitigate in software but don’t really care, is that the speed of sound changes with temperature which may be what is causing the fluctuations in the graph shown below. The peaks seem to match the daily temperature excursion here in Texas. I could definitely reduce the data I take > 100x and more which would likely hide the issue but it works so I won’t mess with it for now.


Note: The levels shown are just meant to match what the Water Softener shows and the numbered bar inside it. When I get to the yellow zone, the toggle (that I should just hide or turn into just a boolean indicator) turns on and other things happen in HA that serve to remind me to buy salt.

As for the case and PCB I used very cheap stuff I had on hand:


For version 2, I will consider the VL53L0X time of flight distance sensor however I need to verify that it can actually do 1m as my salt container is over 1m depending on where I install the sensor. The current sensor is installed 97cm above the bottom so just 3cm from the ToF’s sensor limit (at least according to Adafruit):

3 Likes

@weswitt - You inspired me and I built v2 using the same sensor in the device you used. This version is now powered by the water softener’s power supply (24Vac) and in the future (just wiring up needed), I might be able to detect when the ws runs as I included 2 opto isolated inputs, one of which I plan on connecting to a switch that gets triggered when the valve is turned during operation. Except for the galvanized bracket, everything is stainless steel or covered up (black paint on small screws) to prevent salt induced corrosion that I had on v1. The TOF sensor required me to cut a hole in the lid which I was hoping not to need doing but it didn’t work through the transparent lid. Luckily I purchased a TOF sensor that comes with its own cover so that opening it sealed too. Fuse is just to prevent shorting the power supply in case there is a meltdown in my build.



The one thing that puzzles me is that I seem to be getting an error of -9cm in my readings (-3.5 inches) whole the sensor was quite accurate when I tested it at my desk. How accurate is yours?

great work. i think mine is also off in terms of accuracy. my theory is that the textured surface of the salt confuses the sensor. my idea to improve this is to put a piece of plywood or plastic on top of the salt for reflection.

Hi, I build my own hardware as well. Adding a derivative helper sensor (on HA, not supported by esphome) allows to track current regeneration phase. I explained it here How can I track water softener schedule (using ESPHome and Ultrasonic) - #4 by lcavalli

Looks great.
I have a bunch of TOF sensors left over from another project.
Would you mind sharing the wiring diagram and ESP code so that I can see what I can cobble together.

Thank you so much

@carltonwb - Here is the code however it is customized to my Whirlpool water softener so the levels, percentage, distance of sensor to bottom are all things you may have to change. My goal was to have ESPhome output the same thing shown on the water softener’s screen.

@weswitt has posted code at the very top that is much easier to implement as you only have to make minor changes to adjust it to your water softener.

substitutions:
  devicename: water-softener
  friendly_devicename: "Water Softener"
  device_description: "Water Softener with TOF200C sensor"
  #Distance between bottom of tank and lower edge of lip where sensor is attached.
  #Edge lip bottom to the bottom of canisater is 96.5cm but sensor is a bit lower down so about 95cm?
  #Value in cm as sensor readings are converted to cm right away
  distance_sensor_to_bottom: "95.0"
  #Whirlpool WHES48 has 9 markers starting from 0. 8th can/should be ignored as it is too far up
  #Distance from bottom to top of level number
  top_lvl_7: "79.5" #100%
  top_lvl_6: "70.5" #89
  top_lvl_5: "61.0" #77
  top_lvl_4: "51.5" #65
  top_lvl_3: "42.0" #53
  top_lvl_2: "32.5" #41
  top_lvl_1: "22.5" #28
  top_lvl_0: "12.5" #16
  #Set to 4hrs. Needs to be very long otherwise it overrides the frequency set in the main sensor
  update_interval_s: "60s"
  #Only reason not to set it very long it for wifi troubleshooting
  update_interval_wifi: "120s"
  distance_calibration: "-0.085" #In meters. Must be the salt as outside the tank was more accurate


esphome:
  name: ${devicename}
  comment: ${device_description}  
  friendly_name: ${friendly_devicename}

esp32:
  board: lolin_s2_mini
  variant: esp32s2
  framework:
    type: esp-idf
    version: recommended
  
wifi:
  ssid: !secret iot_wifi_ssid
  password: !secret iot_wifi_password

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "${devicename}"
    password: !secret iot_wifi_password

#Faster than DHCP. Also use if can't reach because of name change
  manual_ip:
    static_ip: 10.1.3.200
    gateway: 10.1.2.1
    subnet: 255.255.254.0
    dns1: 10.1.0.50
    dns2: 10.1.0.51

#Manually override what address to use to connect to the ESP.
#Defaults to auto-generated value. Example, if you have changed your
#static IP and want to flash OTA to the previously configured IP address.
  use_address: 10.1.3.200
  
logger:
    #baud_rate: 0 #disabled

# Enable Home Assistant API
api:
  encryption:
    key: "bEjyWk4O0mgiWhwcPzPXkT6o45M3MZNIlsTtc4gBgHk="
  
ota:
  
web_server:
  port: 80
  include_internal: true

# Sync time with Home Assistant
time:
  - platform: homeassistant
    id: ha_time

text_sensor:
  - platform: wifi_info
    ip_address:
      name: "IP"
      icon: "mdi:ip-outline"
      update_interval: ${update_interval_wifi}
    dns_address:
      name: "DNS"
      icon: "mdi:dns-outline"
      update_interval: ${update_interval_wifi}
    ssid:
      name: "SSID"
      icon: "mdi:wifi-settings"
      update_interval: ${update_interval_wifi}
    bssid:
      name: "BSSID"
      icon: "mdi:wifi-settings"
      update_interval: ${update_interval_wifi}
    mac_address:
      name: "MAC"
      icon: "mdi:network-outline"
    scan_results:
      name: "Wifi Scan"
      icon: "mdi:wifi-refresh"
      update_interval: ${update_interval_wifi}
      disabled_by_default: true

i2c:
  - id: bus_a
    sda: 16
    scl: 18
    scan: true
    frequency: 800khz

binary_sensor:
  - platform: status
    name: "Status"
    id: connection_status
    entity_category: diagnostic

  - platform: gpio
    pin:
      number: 7
      mode:
        input: true
        pulldown: true
    name: Input 1

  - platform: gpio
    pin:
      number: 9
      mode:
        input: true
        pulldown: true
    name: Input 2

sensor:
  - platform: wifi_signal
    name: "WiFi Signal"
    update_interval: ${update_interval_wifi}
    device_class: signal_strength

  - platform: vl53l0x
    name: "Sensor to salt"
    icon: "mdi:format-vertical-align-bottom"
    address: 0x29
    update_interval: 3s
    long_range: true
    timeout: 200us
    id: sensor_to_salt_cm
    state_class: measurement
    unit_of_measurement : "cm"
    accuracy_decimals: 1
    filters:
      - filter_out: nan
      - offset: ${distance_calibration}
      - sliding_window_moving_average:
          window_size: 20 #Both 60 and 120 seem to not affect the cadence, however I would use a smaller number to get rid of outliers sooner
          send_every: 20 #Works out to 10 minutes but not sure why
          send_first_at: 1
      - lambda: return (x*100); #convert from m to cm    
    on_value:
      - sensor.template.publish:
          id: saltLevel
          state: !lambda 'return ${distance_sensor_to_bottom} - id(sensor_to_salt_cm).state ;'

      - sensor.template.publish:
          id: saltTankLevel  #Percentage
          state: !lambda |-
                    x = (id(saltLevel).state / ${top_lvl_7})*100;
                    ESP_LOGD("DEBUG", "saltLevel / top_lvl_7 = %f", x);
                    return x;

          
      - sensor.template.publish:
          id: saltTankLevelNumber
          #Numbers are not equally spaced likely due to widening shape of tank as you go up so
          #can't use simple formula as the one below:
          state: !lambda |-
                    if (id(saltLevel).state <= ${top_lvl_0}) {
                        x = 0;
                    }
                    else if ((id(saltLevel).state > ${top_lvl_0}) and (id(saltLevel).state <= ${top_lvl_1})) {
                        x = 1;
                    }
                    else if ((id(saltLevel).state > ${top_lvl_1}) and (id(saltLevel).state <= ${top_lvl_2})) {
                        x = 2; 
                    }
                    else if ((id(saltLevel).state > ${top_lvl_2}) and (id(saltLevel).state <= ${top_lvl_3})) {
                        x = 3; 
                    }
                    else if ((id(saltLevel).state > ${top_lvl_3}) and (id(saltLevel).state <= ${top_lvl_4})) {
                        x = 4; 
                    }
                    else if ((id(saltLevel).state > ${top_lvl_4}) and (id(saltLevel).state <= ${top_lvl_5})) {
                        x = 5;
                    }
                    else if ((id(saltLevel).state > ${top_lvl_5}) and (id(saltLevel).state <= ${top_lvl_6})) {
                        x = 6;
                    }
                    else if ((id(saltLevel).state > ${top_lvl_6}) and (id(saltLevel).state <= ${top_lvl_7})) {
                        x = 7;
                    }
                    else if (id(saltLevel).state > ${top_lvl_7}) {
                        x = 8;
                        ESP_LOGD("ALERT", "SALT TANK TOO FULL! LEVEL 7 is 100");
                    }
                    return x;
    
      
#Salt Level   
  - platform: template
    id: saltLevel
    name: "Salt Level"
    icon: "mdi:arrow-expand-vertical"
    #lambda: return ${distance_sensor_to_bottom} - id(sensor_to_salt_cm).state ;
    state_class: measurement
    unit_of_measurement: "cm"
    accuracy_decimals: 1
    update_interval: ${update_interval_s}

#Salt Tank Fill Percentage    
  - platform: template
    id: saltTankLevel
    name: "Salt Tank Level"
    icon: "mdi:arrow-expand-vertical"
    state_class: measurement
    unit_of_measurement: "%"
    accuracy_decimals: 0
    update_interval: ${update_interval_s}
    # lambda: |-
    #   auto d = (id(saltLevel).state / ${top_lvl_7})*100;
    #   ESP_LOGD("DEBUG", "top_lvl_7 / saltLevel = %f", d);
    #   return d;

#Manufacturer specific Tank Fill Level  
  - platform: template
    id: saltTankLevelNumber
    name: "Salt Tank Level Number"
    icon: "mdi:arrow-expand-vertical"
    #lambda: return (${distance_sensor_to_bottom} - id(sensor_to_salt_cm).state) / ${cm_per_salt_level_marker};
    state_class: measurement
    unit_of_measurement: "Lvl"
    accuracy_decimals: 0
    update_interval: ${update_interval_s}
    filters:
      - filter_out: nan

button:
  - platform: safe_mode
    name: "Restart (Safe Mode)"
    entity_category: diagnostic

  - platform: restart
    name: "Restart"
    entity_category: diagnostic


  

@lcavalli - Thanks for sharing your code in the other thread. Unfortunately, I cannot see the brine as it never covers the salt so I cannot use that to detect in what phase it is. There is a switch that I can use to detect when the valve turns but I need to find a way to make the connections without modifying the water softener’s original wiring… or maybe I will as it is quite old by now so who cares… I can also see a few extra watts of power consumption when the valve motor is on so technically I can already see when it is active but I wanted the same ESP device to tell me everything and since I had many available GPIO… :slight_smile: (Luca, sono di Roma ma vivo in USA…ciao!)

1 Like