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
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):
@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.
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.
@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… (Luca, sono di Roma ma vivo in USA…ciao!)