DIY ESPHome Salt Level Sensor for a Water Softener (D1 Mini Pro + VL53L0X)

I built a simple salt level sensor for a water softener using a Wemos D1 Mini Pro and a VL53L0X time-of-flight distance sensor, fully integrated with ESPHome and Home Assistant. The sensor measures the distance to the salt surface inside the brine tank and converts it into salt height (cm) and fill level (%). In this post I’m sharing the complete parts list, wiring (I²C on D1/D2), and the working ESPHome configuration, so anyone can replicate the setup and calibrate it easily for their own tank depth.

Connections

VL53L0X Pin D1 Mini Pro PinNotes
VIN3V3Use 3.3V for best stability
GNDGNCCommon ground
SDAD2I²C data
SCLD1I²C clock

Keep the I²C wires short (ideally < 20–30 cm) to avoid unstable readings.

If you must use longer wires, reduce the I²C frequency in ESPHome (e.g. frequency: 20000) and/or add 4.7k pull-ups from SDA→3V3 and SCL→3V3 (only if your breakout board doesn’t already have them).

Make sure the sensor has a clear line of sight to the salt surface (no plastic window in between, and mounted as straight as possible).

ESPHome YAML:

esphome:
  name: softliq-salt-v3
  friendly_name: softliq-salt-v3

esp8266:
  board: d1_mini_pro

logger:

api:
  encryption:
    key: "YOURENCRYPTIONKEY"

ota:
  - platform: esphome
    password: "YOURPASSWORD"

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  ap:
    ssid: "Softliq-Salt-V3 Fallback Hotspot"
    password: "YOURWIFIFALLBACKHOTSPOTPASSWORD"

captive_portal:

# I2C: D2=SDA (GPIO4), D1=SCL (GPIO5)
i2c:
  sda: D2
  scl: D1
  scan: true
  frequency: 20000

sensor:
  # Abstand Sensor -> Salzoberfläche (VL53L0X liefert Meter -> cm)
  - platform: vl53l0x
    name: "Salztank Abstand"
    id: salt_distance_cm
    address: 0x29
    update_interval: 180s
    unit_of_measurement: "cm"
    accuracy_decimals: 1
    # optional: stabiler, aber etwas langsamer
    # long_range: true
    filters:
      - multiply: 100.0
      - sliding_window_moving_average:
          window_size: 3
          send_every: 1
      - clamp:
          min_value: 0.0
          max_value: 60.0

  # Salzhöhe in cm (0..50)
  - platform: template
    name: "Salztank Salzhöhe"
    unit_of_measurement: "cm"
    accuracy_decimals: 1
    update_interval: 180s
    lambda: |-
      const float TANK_DEPTH_CM = 60.0;

      // KALIBRIERUNG:
      const float EMPTY_DISTANCE_CM = 60.0;  // leer (Sensor->Boden)
      const float FULL_DISTANCE_CM  = 10.0;   // voll (Sensor->Salz)

      float d = id(salt_distance_cm).state;
      if (isnan(d)) return NAN;

      float height = EMPTY_DISTANCE_CM - d;
      if (height < 0) height = 0;
      if (height > TANK_DEPTH_CM) height = TANK_DEPTH_CM;
      return height;

  # Füllstand in %
  - platform: template
    name: "Salztank Füllstand"
    unit_of_measurement: "%"
    accuracy_decimals: 0
    update_interval: 5s
    lambda: |-
      const float EMPTY_DISTANCE_CM = 60.0; // leer
      const float FULL_DISTANCE_CM  = 10.0;  // voll

      float d = id(salt_distance_cm).state;
      if (isnan(d)) return NAN;

      float denom = (EMPTY_DISTANCE_CM - FULL_DISTANCE_CM);
      if (denom <= 0.01) return NAN;

      float pct = (EMPTY_DISTANCE_CM - d) / denom * 100.0;
      if (pct < 0) pct = 0;
      if (pct > 100) pct = 100;
      return pct;
Code-Sprache: PHP (php)

The brine tank height is 60 cm, and the “empty” reference distance is 10 cm.

Attached you’ll find the templates for your 3D printer in STL format: one set for the Wemos D1 Mini Pro enclosure (housing + lid) and one set for the sensor enclosure (housing + lid). If your sensor board doesn’t have a protective cover, I recommend gluing a transparent acrylic (plexiglass) sheet on top to protect it from salt mist and deposits.

Download

Some picture:

Home Assistant:

ESPHome 2.9″ Waveshare ePaper (296×128): Clean PV Flow Layout

I’m using a 2.9″ Waveshare ePaper (296×128) on an ESP8266 D1 Mini Pro with ESPHome, pulling all values from Home Assistant
– PV power (sensor.pv_gesamt)
– house consumption (sensor.strom_eigengebrauch)
– grid import/export (sensor.s10x_consumption_from_grid, sensor.s10x_export_to_grid)
– battery charge/discharge + SOC (sensor.s10x_battery_charge, sensor.s10x_battery_discharge, sensor.s10x_state_of_charge).

esp8266:
  board: d1_mini_pro

Script:

captive_portal:

font:
  - file: "gfonts://Roboto+Mono@300"
    id: font_value
    size: 16

  - file: "gfonts://Roboto+Mono@300"
    id: font_home
    size: 26

  - file: "gfonts://Roboto+Mono@300"
    id: font_arrow
    size: 16

  - file: "gfonts://Material+Symbols+Outlined"
    id: icons_28
    size: 28
    glyphs:
      [
        "\U0000e88a", # home
        "\U0000ec0f", # sun
        "\U0000f102", # grid/power (Versorger-Ersatz)
        "\U0000e1a4", # battery 100
        "\U0000ebd2",
        "\U0000ebd4",
        "\U0000ebe2",
        "\U0000ebdd",
        "\U0000ebe0",
        "\U0000ebd9",
        "\U0000ebdc"  # battery 0
      ]

  - file: "gfonts://Material+Symbols+Outlined"
    id: icons_52
    size: 52
    glyphs: ["\U0000e88a"]

spi:
  clk_pin: D0
  mosi_pin: D1

display:
  - platform: waveshare_epaper
    cs_pin: D2
    dc_pin: D3
    busy_pin: D4
    reset_pin: D5
    model: 2.90inv2-r2
    rotation: 90
    reset_duration: 2ms
    update_interval: 15s

    lambda: |-
      const float TH = 0.02f; // 20W (in kW)

      const float pv_kw    = id(pv_gesamt).state;
      const float home_kw  = id(stromverbrauch).state;

      const float grid_in  = id(netzbezug).state;
      const float grid_out = id(einspeisung).state;
      const float grid_net = grid_in - grid_out;

      const float bat_in   = id(inbatterie).state;
      const float bat_out  = id(outbatterie).state;
      const float bat_net  = bat_out - bat_in;

      const float soc      = id(batterieload).state;

      auto fmt_kw = [&](float kw) -> std::string {
        if (isnan(kw)) return std::string("--");
        float w = kw * 1000.0f;
        char buf[18];
        if (fabsf(w) < 1000.0f) snprintf(buf, sizeof(buf), "%.0fW", w);
        else                    snprintf(buf, sizeof(buf), "%.2fkW", kw);
        return std::string(buf);
      };

      auto dotted_h = [&](int x1, int y, int x2, int step, int on) {
        if (x2 < x1) { int t=x1; x1=x2; x2=t; }
        for (int x = x1; x <= x2; x += step) {
          it.line(x, y, std::min(x + on, x2), y);
        }
      };

      auto arrow_right = [&](int x, int y) { it.filled_triangle(x, y, x-7, y-4, x-7, y+4); };
      auto arrow_left  = [&](int x, int y) { it.filled_triangle(x, y, x+7, y-4, x+7, y+4); };

      auto flow_h = [&](int x1, int y, int x2, bool head_at_end) {
        dotted_h(x1, y, x2, 8, 3);
        if (head_at_end) {
          if (x2 > x1) arrow_right(x2, y);
          else         arrow_left(x2, y);
        } else {
          if (x2 > x1) arrow_left(x1, y);
          else         arrow_right(x1, y);
        }
      };

      auto midx = [&](int a, int b) -> int { return a + (b - a) / 2; };

      // Batterie-Icon nach SOC
      std::string battery_icon = "\U0000ebdc";
      if (soc >= 95.0)      battery_icon = "\U0000e1a4";
      else if (soc >= 85.0) battery_icon = "\U0000ebd2";
      else if (soc >= 70.0) battery_icon = "\U0000ebd4";
      else if (soc >= 55.0) battery_icon = "\U0000ebe2";
      else if (soc >= 40.0) battery_icon = "\U0000ebdd";
      else if (soc >= 25.0) battery_icon = "\U0000ebe0";
      else if (soc >= 10.0) battery_icon = "\U0000ebd9";

      // ===== Layout (296x128) =====
      const int HOME_X  = 120;
      const int HOME_Y  = 28;
      const int HOME_CX = 150;

      // Pfeil-Y Positionen
      const int Y_PV   = 32;
      const int Y_GRID = 56;
      const int Y_BAT  = 92;

      // Icons
      const int PV_ICON_X  = 6;
      const int PV_ICON_Y  = 4;

      const int BAT_ICON_X = 6;
      const int BAT_ICON_Y = 74;
      const int BAT_CX     = BAT_ICON_X + 14;

      const int GRID_ICON_X = 268;
      const int GRID_ICON_Y = 44;

      const int X_HOME_LEFT  = HOME_CX - 18;
      const int X_HOME_RIGHT = HOME_CX + 28;

      const int L = 84;
      const int X_LEFT_FLOW  = X_HOME_LEFT - L;
      const int X_RIGHT_FLOW = X_HOME_RIGHT + L;

      const int LABEL_DY = 20;   // <-- war 16

      // ===== Zeichnen =====
      it.printf(PV_ICON_X, PV_ICON_Y, id(icons_28), "\U0000ec0f");

      it.printf(BAT_ICON_X, BAT_ICON_Y, id(icons_28), "%s", battery_icon.c_str());
      char soc_buf[8];
      snprintf(soc_buf, sizeof(soc_buf), "%.0f%%", soc);
      it.printf(BAT_CX, BAT_ICON_Y + 30, id(font_value), TextAlign::TOP_CENTER, "%s", soc_buf);

      it.printf(HOME_X, HOME_Y, id(icons_52), "\U0000e88a");

      it.printf(HOME_CX, 88, id(font_home), TextAlign::TOP_CENTER, "%s", fmt_kw(home_kw).c_str());

      it.printf(GRID_ICON_X, GRID_ICON_Y, id(icons_28), "\U0000f102");

      // PV -> Home
      if (!isnan(pv_kw) && pv_kw > TH) flow_h(X_LEFT_FLOW, Y_PV, X_HOME_LEFT, true);
      else                             dotted_h(X_LEFT_FLOW, Y_PV, X_HOME_LEFT, 8, 3);

      float pv_show = (!isnan(pv_kw) && pv_kw > TH) ? pv_kw : 0.0f;
      it.printf(midx(X_LEFT_FLOW, X_HOME_LEFT), Y_PV - LABEL_DY, id(font_arrow),
                TextAlign::TOP_CENTER, "%s", fmt_kw(pv_show).c_str());

      // Akku <-> Home
      bool bat_active = (!isnan(bat_net) && fabsf(bat_net) > TH);
      if (bat_active) {
        if (bat_net > 0) flow_h(X_LEFT_FLOW, Y_BAT, X_HOME_LEFT, true);
        else             flow_h(X_HOME_LEFT, Y_BAT, X_LEFT_FLOW, true);
      } else {
        dotted_h(X_LEFT_FLOW, Y_BAT, X_HOME_LEFT, 8, 3);
      }

      float bat_show = (bat_active) ? fabsf(bat_net) : 0.0f;
      it.printf(midx(X_LEFT_FLOW, X_HOME_LEFT), Y_BAT - LABEL_DY, id(font_arrow),
                TextAlign::TOP_CENTER, "%s", fmt_kw(bat_show).c_str());

      // Netz <-> Home
      bool grid_active = (!isnan(grid_net) && fabsf(grid_net) > TH);
      if (grid_active) {
        if (grid_net > 0) flow_h(X_RIGHT_FLOW, Y_GRID, X_HOME_RIGHT, true);
        else              flow_h(X_HOME_RIGHT, Y_GRID, X_RIGHT_FLOW, true);
      } else {
        dotted_h(X_HOME_RIGHT, Y_GRID, X_RIGHT_FLOW, 8, 3);
      }

      float grid_show = (grid_active) ? fabsf(grid_net) : 0.0f;
      it.printf(midx(X_HOME_RIGHT, X_RIGHT_FLOW), Y_GRID - LABEL_DY, id(font_arrow),
                TextAlign::TOP_CENTER, "%s", fmt_kw(grid_show).c_str());

sensor:
  - platform: homeassistant
    id: stromverbrauch
    entity_id: sensor.strom_eigengebrauch
  - platform: homeassistant
    id: pv_gesamt
    entity_id: sensor.pv_gesamt
  - platform: homeassistant
    id: netzbezug
    entity_id: sensor.s10x_consumption_from_grid
  - platform: homeassistant
    id: einspeisung
    entity_id: sensor.s10x_export_to_grid
  - platform: homeassistant
    id: inbatterie
    entity_id: sensor.s10x_battery_charge
  - platform: homeassistant
    id: outbatterie
    entity_id: sensor.s10x_battery_discharge
  - platform: homeassistant
    id: batterieload
    entity_id: sensor.s10x_state_of_charge

web_server:
  port: 80Code-Sprache: PHP (php)

Preview: