Has anyone tried the new Shelly BLU Distance yet? out of stock at the moment but looks promising.
Has anyone done anymore work on this project? I’m starting to build out a sensor loosely based around @aruffell box above.
Was thinking of adding a small tube under the TOF200C to limit its field of view from 25-degrees down to a more limited cone that won’t hit the side walls of the tank, etc.
@karsongp - While trying to let the salt tank empty I realized that the sensor was reading bounced signals… or something else was going on. When full, it appears to work fine, but that doesn’t work for me :). I did add a tube to limit its view but it still doesn’t work properly.
When the hardware is not pointed down at the salt, it is accurate so it has something to do with the shape of the tank or the salt, or both.
I now have a 3D printer, so I am thinking about making a bracket that allows me to install this in a more central location over the salt. Thank you for reminding me that I have yet to solve this issue. Let me know what you discover… when you test, don’t only test with a full tank. The most important test is what it says when you are getting close to needing to get more salt after all.
@aruffell Oh, nice to get an update from you!
I never really fill my tank past 5 so it will be important that it can get that far down and report as it lowers. My tank is down to level 1-2 right now and I plan to build/test this week so I should be able to troubleshoot from the low side.
I use the Whirlpool WHES33, a slightly smaller model I believe so I will have more wall bouncing I assume.
Has anyone tried using the TOF400C ?
Hey @aruffell - did more testing tonight. Haven’t hit the tank yet, but bench testing and I made two changes which helped range. Change the timing_budget to 200ms, not the default 33ms. Remove the timeout: 200us. Let me know if those help
@karsongp - ChatGPT had already suggested I change the timeout to what you found as well. I did not have timing_budget so I added it as you suggested. Thank you.
My current YAML - in case it helps - is:
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 need to measure once empty.
#Value in cm as sensor readings are converted to cm right away
distance_sensor_to_bottom: "95.0"
#Salt level under which - no salt - is reported. Doing so under 12.5 - level 1 - is misleading.
empty_cm: "5.0" # treat <= 5cm as empty
#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: "360s"
#Only reason not to set it very long it for wifi troubleshooting
update_interval_wifi: "300s"
#distance_calibration: "-0.045" #Add 4.5cm expressed in meters. Why is it so off, used to be less. Degraded over time?
distance_calibration: "0.00" #Add 22cm expressed in meters. Why is it so off, used to be less. Degraded over time?
sensor_reporting_throttle: "360s"
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:
ota:
- platform: esphome
allow_partition_access: true
version: 2
web_server:
port: 80
version: 2
include_internal: true
ota: false
# Sync time with Home Assistant
time:
- platform: homeassistant
id: ha_time
text_sensor:
- platform: wifi_info
ip_address:
name: "IP"
id: device_ip
icon: "mdi:ip-outline"
dns_address:
name: "DNS"
icon: "mdi:dns-settings"
ssid:
name: "SSID"
icon: "mdi:wifi-settings"
bssid:
name: "BSSID"
icon: "mdi:wifi-settings"
mac_address:
name: "MAC"
icon: "mdi:network-outline"
scan_results:
name: "Wifi Scan"
icon: "mdi:wifi-refresh"
disabled_by_default: true
internal: true
i2c:
- id: bus_a
sda: 16
scl: 18
scan: true
#frequency: 400khz
#timeout (Optional, Time): Set the I²C bus timeout. Defaults to the framework defaults (100us on esp32 with esp-idf,
#50ms on esp32 with Arduino, 1s on esp8266 and 1s on rp2040). Maximum on esp-idf is 13ms.
timeout: 100ms
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 (Optional, int): Manually specify the i2c address of the sensor. Defaults to 0x29. If an address other the 0x29 is
#specified, the sensor will be dynamically re-addressed at startup. A dynamic re-address of sensor requires the enable_pin
#configuration variable to be assigned. If more then one VL53L0X sensor is used on the same i2c bus, a unique address must
#be specified per sensor.
address: 0x29
#enable_pin (Optional, Pin Schema): The pin connected to XSHUT on vl53l0x to enable/disable sensor. Required if not using
#address 0x29 which is the cause if you have multiple VL53L0X on the same i2c bus. In this case you have to assign a different
#pin to each VL53L0X.
update_interval: 2s
#signal_rate_limit (Optional, float): Set the return signal rate limit in units of MCPS (mega counts per second). This is the
#minimum signal amplitude detected by the sensor necessary for it to report a valid reading. Setting a lower value may increase
#the range of the sensor but also increases the chance of getting inaccurate readings. Defaults to 0.25.
#AMR: If you start seeing nan more often, back off a little (0.35 to 0.45) - should help with better accuracy
signal_rate_limit: 0.5
#long_range (Optional, boolean): Set the sensor in long range mode. The signal_rate_limit is overruled to 0.1. Defaults to false.
long_range: false
#timeout (Optional, Time): Sensor setup timeout. Default to 10ms.
#timeout: 200us chatGPT blames precision issues with this
timeout: 10ms
timing_budget: 200ms
id: sensor_to_salt_cm
state_class: measurement
unit_of_measurement : "cm"
accuracy_decimals: 1
filters:
- filter_out: nan
- offset: ${distance_calibration} # meters
- lambda: return (x*100); # m -> cm
# - sliding_window_moving_average:
# window_size: 5
# send_every: 5
# send_first_at: 1
# A moving average is great for Gaussian noise, but ToF over salt tends to have occasional “wrong surface” readings. Median crushes that.
- median:
window_size: 11
send_every: 11
send_first_at: 11
- or:
- throttle: ${sensor_reporting_throttle}
- delta: 1.0
on_value:
# - sensor.template.publish:
# id: saltLevel
# state: !lambda 'return ${distance_sensor_to_bottom} - id(sensor_to_salt_cm).state ;'
- lambda: |-
float sensor_to_salt_cm = x; // already calibrated+converted due to filters
float offset_cm = ${distance_calibration} * 100.0f;
float salt_cm = ${distance_sensor_to_bottom} - sensor_to_salt_cm;
ESP_LOGD(
"SALT_CAL",
"sensor_to_salt=%.2f cm (includes offset %.2f cm), sensor_to_bottom=%.2f cm, saltLevel=%.2f cm",
sensor_to_salt_cm,
offset_cm,
${distance_sensor_to_bottom},
salt_cm
);
id(saltLevel).publish_state(salt_cm);
- sensor.template.publish:
id: saltTankLevel #Percentage
state: !lambda |-
float pct = (id(saltLevel).state / ${top_lvl_7}) * 100.0f;
if (pct < 0) pct = 0;
if (pct > 100) pct = 100;
ESP_LOGD("DEBUG", "Fill percentage -> saltLevel / top_lvl_7 = %f", pct);
return pct;
# - sensor.template.publish:
# id: saltTankLevelNumber
# #Numbers in tank are not spaced evenly because tank is narrow at the bottom, and gets wider as you go up so
# #I can't use a simple formula as the one further below in the code:
# 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;
- sensor.template.publish:
id: saltTankLevelNumber
#Numbers in tank are not spaced evenly because tank is narrow at the bottom, and gets wider as you go up so
#I can't use a simple formula as the one further below in the code:
state: !lambda |-
float h = id(saltLevel).state;
if (h <= ${empty_cm}) {
x = 0; // truly empty
}
else if (h <= ${top_lvl_0}) {
x = 1; // some salt, but not up to marker 0
}
else if (h <= ${top_lvl_1}) {
x = 2;
}
else if (h <= ${top_lvl_2}) {
x = 3;
}
else if (h <= ${top_lvl_3}) {
x = 4;
}
else if (h <= ${top_lvl_4}) {
x = 5;
}
else if (h <= ${top_lvl_5}) {
x = 6;
}
else if (h <= ${top_lvl_6}) {
x = 7;
}
else if (h <= ${top_lvl_7}) {
x = 8;
}
else {
x = 8;
ESP_LOGD("ALERT", "SALT TANK TOO FULL!");
}
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
As this is what I show in the UI:
The graph at the bottom is suspect. The decrease should happen in steps every X days whenever it regenerates. The daily up and down seems to be affected by temperature? I know the ultrasonic sensor I was using before was affected by it, but I thought laser would not be as much.
@aruffell Thanks… I’m finalizing my build and will send up my info when I have a couple of weeks of usage.
Unsure how you have the sensor mounted in your box, but I found in testing that if my chassis was too close to the side wall of the brine tank, if would report inaccurate numbers. I’ve moved it out approx 3 inches to hover more at the center of the salt, between the side of the resin tank and the back wall.
@karsongp - I am quite sure that is my issue. Mine is on the left side, close to the wall…
Sensor is on the side where the gray wire comes out. I power my sensor off of the 24Vac (gray wire).
How/where do I get the [quote=“jfgreco, post:62, topic:717439”]
Mosquitto Bridge Config (HA Add-on)
[/quote]
I ended up building a separate project around the same idea, after running into many of the calibration and unreliable-reading problems discussed here.
It is called SaltWatch and uses an M5Stack ATOM Lite with the M5Stack VL53L0X ToF unit mounted under the lid. The ATOM stays outside the tank. It reports the calibrated salt percentage, distance, low salt state, calibration status, and sensor health directly through ESPHome.
One part I spent quite a bit of time on was failure handling. If the sensor is blocked, disconnected, or stops returning valid measurements, the old level is removed instead of continuing to look current. It also learns the normal decline over time and can estimate the number of days until the low salt threshold is reached.
There is a matching Home Assistant card that shows the level, threshold, status, and forecast in one place.
Firmware and installation:
Home Assistant card:
This is a separate hardware build, not replacement firmware for the EZsalt device. The firmware can be installed from a browser and then adopted into ESPHome.
It is still a young project, so I would really appreciate testers with different tanks, salt types, and installation conditions. Issues, test results, and PRs are all welcome.
I have a gen2 and this was an awesome idea, thanks!
I just setup alerts, but, I like having it on a dashboard. I can now lose their app too, win.
type: vertical-stack
cards:
-
type: markdown
content: |
WATER SOFTENER -
type: horizontal-stack
cards:-
type: gauge
entity: sensor.water_softener_salt_level_percentage
name: Salt Level
min: 0
max: 100
needle: true
severity:
green: 40
yellow: 20
red: 0 -
type: entity
entity: sensor.water_softener_salt_status
name: Status
icon: mdi:water-check
-
-
type: horizontal-stack
cards:-
type: entity
entity: sensor.water_softener_salt_level
name: Salt Depth
icon: mdi:shaker-outline -
type: entity
entity: sensor.water_softener_salt_distance
name: Sensor Distance
icon: mdi:arrow-expand-vertical
-
-
type: history-graph
title: Salt Level — Last 30 Days
hours_to_show: 720
entities:- entity: sensor.water_softener_salt_level_percentage
name: Salt Level
- entity: sensor.water_softener_salt_level_percentage



