Salt Level Sensor

Monitors the salt level in a water softener

Overview

This battery-powered IoT device monitors the salt level in a water softener.

Note that there are multiple versions of the hardware and firmware in the Git repository - I initially tried doing this with an ultrasonic distance sensor but the shape of the internal cavity where the salt is stored made this unreliable.

It sits on top of the salt tank and points a time-of-flight sensor downward - the further the reading, the lower the salt. Every few hours, it wakes up, takes a distance measurement, connects to WiFi, uploads the data to a server, and then kills its own power supply until the RTC alarm fires again. The server picks the best pixel from the centre of the grid, converts distance to a percentage, stores it in a database, and sends email alerts if the salt is running low or the battery needs replacing. A Grafana dashboard provides historical visibility.

  • 4-layer PCB
  • ESP32-C3 (RISC-V), using ESP-IDF/FreeRTOS
  • VL53L5CX multizone ToF sensor
  • RV-8263-C7 RTC
  • Go server with MySQL
  • SMTP email alerts
  • Grafana for reporting (https://vibue.com)
  • ESP-SmartConfig for WiFi provisioning

PCB

It's a standard 1.6mm 4 layer PCB, both internal layers are solid ground.

Front
Back

Schematic

The schematic is simple enough for a single sheet.

Schematic

Power Consumption

The distance sensor runs on three AA batteries. A dual-LDO power architecture allows the ESP32-C3 to cut its own power supply after each reading. Only the RTC and its dedicated low-quiescent LDO remain powered up - I measured it at around 1.3 uA. Worst case, it could draw up to ~4uA but I never saw anything close to that.

So three AAs could theoretically sustain the sleep circuit for well over a century (notwithstanding battery leakage current) - so battery life is governed entirely by how often the device wakes to take a reading and transmit over WiFi. The sleep current is negligible.

Firmware

Boot sequence

The device cold-boots every wake cycle (not resume from deep sleep - it's been fully unpowered). app_main() runs the operational sequence:

  • Power on - drives PWR_EN high, which enables LDO1 via the Q2 MOSFET pair, bringing up the main 3V3 rail. This powers the ToF sensor and the rest of the circuit.
  • Read battery voltage - enables VBSNS_EN to connect VBAT through a resistive divider to the ADC, takes a reading, then disconnects the battery sensing circuit.
  • Read RTC clock - reads the current time from the RTC (RV-8263-C7) for calculating the next alarm wakeup time.
  • Initialise WiFi - starts WiFi in station mode. If credentials are already stored in NVS, it connects immediately. If not (fresh device or after factory reset), it starts SmartConfig - the user configures WiFi from a phone app.
  • Read distance - initialises the VL53L5CX, configures it and polls for up to 200ms until a reading is available. The result is a grid of 8x8 distance, sigma (confidence), and reflectance values.
  • Wait for WiFi - polls up to 30 seconds for a WiFi connection
  • Upload reading - HTTP POSTs the sensor data to the server as a binary payload. The server responds with a JSON object containing sleep_count (seconds until the next reading).
  • Set RTC alarm - calculates the absolute alarm time by adding sleep_seconds to the current time-of-day, writes the alarm registers to the RTC.
  • Power off - drives PWR_EN low, killing LDO1 and therefore the ESP32's own power supply. The code after this line only runs if the FACTORY button is holding power on.

Factory reset

If power_off() doesn't actually power the device off (because the user is holding the FACTORY button), the code enters a factory reset sequence. It flashes the LED at 2 Hz for 5 seconds - if the button is still held after that, it erases NVS and reboots which puts the device back into SmartConfig mode.

Server

The server stores each reading with a timestamp. It tracks devices by MAC address and returns a sleep_count value that controls how often the device wakes. The server also maintains per-device distance ranges, sigma thresholds, and reflectance minimums for data validation. There's a daily alarm system and email notifications.

Server code is a straightforward Go HTTP service backed by MySQL. It receives the full 8x8 grid and picks the best reading from the centre 4x4 pixels - highest reflectance wins, with ties broken by furthest distance. This is to make it reliable when looking down at the salt blocks - it picks the most confident measurement of the salt surface.

Devices auto-register on first contact using their MAC address. The server stores each reading (distance, sigma, reflectance, battery voltage, RSSI, timestamp) and responds with a sleep_count telling the device how many seconds to sleep before the next reading - configurable per-device, defaulting to 6 hours.

There's a daily alarm at 6AM UTC that checks every device's most recent reading against per-subscriber thresholds for salt level, battery voltage, and time-since-last-report. If anything's off, it sends an HTML email with a link to the device's Grafana dashboard.