Рівень води в баку: ультразвуковий датчик і веб-індикатор
Ультразвуковий датчик над поверхнею води міряє відстань, ESP32 перетворює її на відсоток заповнення й показує на сторінці з анімованим баком. Для дачі, бочки з дощовою водою чи септика.
Принцип
Датчик надсилає ультразвуковий імпульс і міряє, скільки часу він летить до поверхні води й назад. Знаючи швидкість звуку (~343 м/с), отримуємо відстань, а з неї й рівень:
┌── датчик ──┐
│ ↕ d │ d = відстань до води
│~~~~~~~~~~~~~│ ← поверхня
│ вода │
└─────────────┘ TANK_DEPTH = від датчика до дна
рівень % = (TANK_DEPTH − d) / (TANK_DEPTH − FULL_GAP) × 100
де FULL_GAP — відстань від датчика до води, коли бак повний.
Який датчик обрати
| Датчик | Для чого | Мін. відстань |
|---|---|---|
| HC-SR04 | тести на столі, сухі місця | 2 см |
| JSN-SR04T / AJ-SR04M | вологий бак, вулиця (герметичний) | ~20–25 см |
Для реального бака беріть JSN-SR04T: у HC-SR04 від конденсату швидко окиснюються контакти. Обидва мають однакові виводи TRIG/ECHO й працюють з тим самим кодом.
Підключення
| Датчик | ESP32 |
|---|---|
| VCC | 5V (VIN) |
| GND | GND |
| TRIG | GPIO 5 |
| ECHO | GPIO 18 через дільник 1 кОм / 2 кОм |
Вихід ECHO видає 5 В, а входи ESP32 розраховані на 3.3 В. Дільник: ECHO → 1 кОм → GPIO 18 → 2 кОм → GND.
Код
Вкажіть розміри свого бака в сантиметрах:
#include <WiFi.h>
#include <WebServer.h>
const char* WIFI_SSID = "YOUR_SSID";
const char* WIFI_PASS = "YOUR_PASSWORD";
const int TRIG_PIN = 5;
const int ECHO_PIN = 18;
const float TANK_DEPTH_CM = 120.0; // від датчика до дна
const float FULL_GAP_CM = 25.0; // від датчика до води, коли бак повний
WebServer server(80);
float distanceCm = NAN;
float levelPercent = NAN;
unsigned long lastMeasure = 0;
float measureOnce() {
digitalWrite(TRIG_PIN, LOW);
delayMicroseconds(2);
digitalWrite(TRIG_PIN, HIGH);
delayMicroseconds(10);
digitalWrite(TRIG_PIN, LOW);
unsigned long us = pulseIn(ECHO_PIN, HIGH, 30000); // тайм-аут ≈ 5 м
if (us == 0) return NAN;
return us * 0.0343 / 2.0;
}
// Медіана з 5 замірів відкидає випадкові відбиття від стінок
float measureMedian() {
float v[5];
int n = 0;
for (int i = 0; i < 5; i++) {
float d = measureOnce();
if (!isnan(d)) v[n++] = d;
delay(60);
}
if (n == 0) return NAN;
for (int i = 1; i < n; i++) { // сортування вставками
float x = v[i];
int j = i - 1;
while (j >= 0 && v[j] > x) { v[j + 1] = v[j]; j--; }
v[j + 1] = x;
}
return v[n / 2];
}
void update() {
distanceCm = measureMedian();
if (isnan(distanceCm)) {
levelPercent = NAN;
return;
}
float pct = (TANK_DEPTH_CM - distanceCm) / (TANK_DEPTH_CM - FULL_GAP_CM) * 100.0;
levelPercent = constrain(pct, 0.0f, 100.0f);
}
String jsonNumber(float v) {
return isnan(v) ? String("null") : String(v, 1);
}
const char PAGE[] = R"rawliteral(
<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Tank level</title>
<style>
body{font-family:system-ui;display:grid;place-items:center;min-height:100vh;margin:0;background:#f4f7fa}
.tank{width:160px;height:260px;border:4px solid #334;border-radius:0 0 18px 18px;position:relative;overflow:hidden;background:#fff}
.water{position:absolute;bottom:0;left:0;right:0;background:#2a7fd4;transition:height 1s}
.pct{font-size:2.6rem;font-weight:700;text-align:center;margin-top:16px}
.muted{color:#667;text-align:center}
</style></head><body>
<div>
<div class="tank"><div class="water" id="w" style="height:0"></div></div>
<div class="pct" id="p">--%</div>
<div class="muted" id="d"></div>
</div>
<script>
async function update() {
const r = await (await fetch('/data')).json();
if (r.level === null) { document.getElementById('p').textContent = 'no echo'; return; }
document.getElementById('w').style.height = r.level + '%';
document.getElementById('p').textContent = Math.round(r.level) + '%';
document.getElementById('d').textContent = r.distance + ' cm to surface';
}
update();
setInterval(update, 5000);
</script></body></html>
)rawliteral";
void setup() {
Serial.begin(115200);
pinMode(TRIG_PIN, OUTPUT);
pinMode(ECHO_PIN, INPUT);
WiFi.begin(WIFI_SSID, WIFI_PASS);
while (WiFi.status() != WL_CONNECTED) delay(500);
Serial.println(WiFi.localIP());
server.on("/", [] { server.send(200, "text/html", PAGE); });
server.on("/data", [] {
server.send(200, "application/json",
"{\"level\":" + jsonNumber(levelPercent) + ",\"distance\":" + jsonNumber(distanceCm) + "}");
});
server.begin();
update();
}
void loop() {
server.handleClient();
if (millis() - lastMeasure > 10000) { // рівень змінюється повільно — раз на 10 с достатньо
lastMeasure = millis();
update();
}
}
Монтаж
- Встановіть датчик строго вертикально по центру кришки, подалі від стінок і труби наливу.
- Врахуйте «мертву зону»: для JSN-SR04T вода має бути щонайменше на 25 см нижче датчика — тому й
FULL_GAP_CM. - Електроніку розмістіть у герметичній коробці поза баком, у бак опускається лише головка датчика на кабелі.
Точність
- Швидкість звуку залежить від температури: ±10 °C дають похибку ~2 %. Для точніших вимірів додайте DS18B20 і рахуйте
v = 331.3 + 0.606 × Tм/с. - Хвилі й піна дають хибні відбиття — медіанний фільтр із коду їх відсікає.
Ідеї
- Сповіщення в Telegram, коли рівень нижче 20 % (див. проєкт «Telegram-бот»).
- Автоматичне ввімкнення насоса наповнення з гістерезисом: увімкнути при 30 %, вимкнути при 90 %.
- Графік витрати води за добу в Home Assistant через MQTT.