← До всіх проєктів
Сенсори Початковий

Рівень води в баку: ультразвуковий датчик і веб-індикатор

Ультразвуковий датчик над поверхнею води міряє відстань, ESP32 перетворює її на відсоток заповнення й показує на сторінці з анімованим баком. Для дачі, бочки з дощовою водою чи септика.

Складність
Початковий
Час
1–2 h
Категорія
Сенсори

Принцип

Датчик надсилає ультразвуковий імпульс і міряє, скільки часу він летить до поверхні води й назад. Знаючи швидкість звуку (~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.