Умная система обнаружения утечек воды и управления клапаном
Компоненты и материалы
| Алюминиевый корпус | × | 1 |
| Arduino UNO | × | 1 |
| Arduino Ethernet Shield 2 | × | 1 |
| Блок питания | × | 1 |
| Корпус для переменного тока | × | 1 |
| Набор светодиодов с резисторами | × | 1 |
| Реле (1 двухканальное 5В и 1 двухканальное 12В) | × | 1 |
| Моторизованный клапан | × | 1 |
| Разъемы JST | × | 1 |
| Датчик воды | × | 1 |
О проекте
Обзор
Во время разговора с другом я понял, что утечки воды — серьезная проблема. Мой друг был вынужден заменить всю мебель в подвале, потому что труба лопнула, пока он был на работе.
Этот проект похож на тот, что я публиковал для конкурса Microsoft IoT, но он основан на Arduino, а не на Raspberry Pi. Мой подход: вместо централизации множества задач на мощной платформе (Raspberry Pi, DragonBoard или ПК) я предпочитаю делегировать простые задачи простым устройствам (Arduino и другим). Они выполняют свою работу и при необходимости подключаются по сети к другим устройствам для предоставления расширенных сервисов. В случае сбоя сети они продолжают выполнять свои функции.
По сути, система мониторит утечки воды и запускает действия, такие как перекрытие главного водопровода, в зависимости от обнаруженной неисправности.
Она также публикует сообщения в MQTT-брокере. Идея в том, что устройство управляет водоснабжением локально, но также участвует в более крупной системе домашней автоматизации.
Внешний вид системы

Главный водопровод находится внизу. Первое устройство установлено городскими водоканалами для контроля давления. Я установил свою часть сверху. Моторизованный клапан (синий) установлен параллельно ручному. На фото ручной клапан открыт, а моторизованный обойден — это полезно при отключении электричества. В нормальном режиме ручной клапан должен быть закрыт.
Внутри клапана находится двигатель постоянного тока (12В), вращающийся по или против часовой стрелки в зависимости от полярности. Есть обратная связь, показывающая, открыт или закрыт клапан. Для открытия нужно подать плюсовое напряжение на верхний левый контакт.
Контроллер

Слева направо: сетевая вилка, кнопка сброса, светодиоды статуса, разъемы (к датчикам, к двигателю), Ethernet и USB интерфейсы.
- LED1 (красный): горит постоянно — вода обнаружена локально; мигает — вода обнаружена удаленно; выключен — утечек нет.
- LED2 (желтый): горит постоянно — невозможно управлять моторизованным клапаном; мигает — нет связи с MQTT-брокером; выключен — всё в порядке.
- LED3 (синий): горит постоянно — всё в порядке, клапан открыт, мониторинг активен; мигает — клапан закрыт; выключен — система выключена или не запитана.

Датчик установлен в месте потенциальной утечки.

⚠ ВАЖНОЕ ПРЕДУПРЕЖДЕНИЕ!
Используемый мною AC/DC блок питания имеет два выхода: первый — 12В DC для питания моторизованного клапана (управляется двумя реле, переключающими полярность), второй — ровно 5В DC для питания Arduino. Поэтому я запитываю Arduino напрямую на 5В цепи, а не через Vin (который требует минимум 6В). После подключения AC/DC блока НИКОГДА не подключайте ни DC-джек Arduino, ни USB-кабель. Если нужна отладка по USB, сделайте кабель без линий питания, оставив только линии данных. Кстати, связь между AC-корпусом и блоком питания — 110В. Не касайтесь!
Программный код
Обнаружение утечек и управление моторизованным клапаном (Arduino)
#include <PubSubClient.h>
#include <Network.h>
#include <Timer.h>
#include <SPI.h>
#include <Ethernet.h>
#include <Dns.h>
#include <avr/wdt.h>
/* Принцип работы оборудования:
Три подсистемы:
- Основной блок:
- Arduino Uno с Ethernet Shield
- Красный LED: постоянно — вода локально, мигает — вода удаленно, выкл. — нет утечки
- Желтый LED: постоянно — клапан неисправен, мигает — MQTT брокер недоступен, выкл. — ОК
- Синий LED: постоянно — клапан открыт, мониторинг активен; мигает — клапан закрыт; выкл. — система внизу
- Кнопка: при нажатии запускает самотестирование
- Двухканальное реле для управления удаленным моторизованным клапаном
- Другое двухканальное реле для опроса концевых выключателей на клапане
- Набор датчиков воды (все параллельно) (все 3 разъема на панели соединены параллельно)
Моторизованный клапан имеет соединения:
- Желтый и Синий: DC для питания двигателя
- Черный: вход концевых выключателей (подключаем к GND)
- Красный: становится GND при достижении полностью закрытого положения (примечание: из-за конструкции концевого выключателя нет гарантии сохранения контакта после отключения питания)
- Зеленый: становится GND при достижении полностью открытого положения (аналогично)
*/
// Сетевые настройки
byte mac[] = { 0xDE, 0xAD, 0xBE, 0xCF, 0xFC, 0xEE }; // MAC Arduino
IPAddress ip(192, 168, 12, 215); // IP Arduino
IPAddress server(192, 168, 12, 130); // IP MQTT брокера
EthernetClient ethClient;
PubSubClient client(ethClient);
#define mqttClientPrefix "GLX"
#define mqttClientLocation "BASEMENT"
#define mqttClientUID "001"
#define mqttClientStatusTopic "Status"
#define mqttClientFaultTopic "Fault"
const int mqttInterval = 20; // интервал отчетов в MQTT (в циклах основного цикла)
int mqttIntervalCnt = 0;
int isConnectedToBroker = -1; // 1 — подключено, -1 — неизвестно, 0 — не удалось подключиться
// Распиновка
const int SystemLedPin = A0; // Синий LED
const int FaultLedPin = A1; // Желтый LED
const int AlarmLedPin = A2; // Красный LED
const int WaterDetectorPin = 2; // LOW при обнаружении воды, иначе подтянут к VCC
const int ToggleButtonPin = 3; // LOW при нажатии, иначе подтянут к GND
const int SdCardPin = 4; // SD карта на Ethernet Shield, не используется
const int ValveClosedPin = 5; // LOW при срабатывании концевого выключателя "закрыто", иначе подтянут к HIGH
const int ValveOpenedPin = 6; // LOW при срабатывании концевого выключателя "открыто", иначе подтянут к HIGH
const int ValveControl1 = 8; // управление первым реле клапана
const int ValveControl2 = 7; // управление вторым реле клапана
// D10-D13 зарезервированы для Ethernet Shield
// Состояния
int isWaterDetected = 0; // локальная утечка
int isWaterDetectedRemotely = 0; // удаленная утечка
int isValveClosed = -1; // статус клапана (-1 неизвестно, 0 открыт, 1 закрыт)
const int valveTimeOut = 15; // сек, макс. время открытия/закрытия
int isConnectedToValve = -1; // 0 — не можем управлять клапаном, 1 — можно, -1 — неизвестно
volatile boolean isResetRequested = 0; // флаг запроса сброса (из прерывания)
const int mainLoopDelay = 500; // задержка основного цикла, мс
void(* resetFunc) (void) = 0; // функция программного сброса
void setup() {
wdt_disable();
Serial.begin(9600);
Serial.println(F("Begin of setup"));
// Настройка пинов
pinMode(SystemLedPin, OUTPUT);
pinMode(FaultLedPin, OUTPUT);
pinMode(AlarmLedPin, OUTPUT);
pinMode(WaterDetectorPin, INPUT);
pinMode(ToggleButtonPin, INPUT);
pinMode(ValveOpenedPin, INPUT); // реле 12В по умолчанию неактивно, пин подключен к NO контакту с подтяжкой -> HIGH
pinMode(ValveClosedPin, INPUT); // аналогично
pinMode(ValveControl1, OUTPUT); digitalWrite(ValveControl1, HIGH); // реле 5В 1 неактивно -> двигатель на GND
pinMode(ValveControl2, OUTPUT); digitalWrite(ValveControl2, HIGH); // реле 5В 2 неактивно -> двигатель на GND
pinMode(SdCardPin, OUTPUT); digitalWrite(SdCardPin, HIGH); // отключаем SD карту
// Самотест LED
testLeds();
// Сеть и MQTT
client.setServer(server, 1883);
client.setCallback(MQTTBrokerCallback);
Ethernet.begin(mac, ip);
Serial.print(F("Current IP is : "));
Serial.print(Ethernet.localIP());
Serial.print(F(" - MQTT broker IP is : "));
Serial.println(server);
// Изначально статус клапана неизвестен, концевые выключатели ненадежны.
// Открываем клапан и ждем завершения. Худший случай — уже открыт, кратковременно сработает концевой выключатель.
if (openValve() == 0) {
Serial.println(F("Valve is open and system is now monitoring"));
subscribeToRemoteWaterSensors();
} else {
Serial.println(F("Unable to open valve, system is out of order. Please use plumbing bypass"));
}
enableInterruptOnResetButton();
delay(1500);
Serial.println(F("End of setup"));
}
void loop() {
configureLedsWithInitialStates();
// Реакция на запрос сброса
if (isResetRequested == 1) {
Serial.println(F("Someone pushed on the button to reset this device"));
publishStatus();
wdt_enable(WDTO_1S); // включить watchdog, сработает через 1 сек
delay(5000);
Serial.println(F("this message should never appear"));
}
// Проверка локального датчика воды
readLocalWaterSensor();
if (isWaterDetected == 1 || isWaterDetectedRemotely == 1) {
if (isValveClosed == 0) closeValve();
}
// Публикация в MQTT
if (mqttIntervalCnt == 0) {
if (isWaterDetected == 1) publishFault();
publishStatus();
mqttIntervalCnt = mqttInterval;
} else {
if (isConnectedToValve == 0) {
Serial.println(F("System is out of order - unable to control motorized valve. No monitoring in place"));
} else {
Serial.print(F("."));
}
mqttIntervalCnt--;
}
delay(mainLoopDelay / 2);
client.loop();
configureLedsWithFinalStates();
delay(mainLoopDelay / 2);
}
// Чтение локального датчика воды с дебаунсом
void readLocalWaterSensor() {
isWaterDetected = !getDebouncedValue(WaterDetectorPin, 100, 10);
Serial.print(isWaterDetected);
}
// Управление кнопкой сброса (прерывание)
void enableInterruptOnResetButton() {
isResetRequested = 0;
attachInterrupt(1, onResetRequested, CHANGE);
}
void onResetRequested() {
detachInterrupt(1);
isResetRequested = 1;
}
// Открытие клапана
int openValve() {
Serial.print(F("Opening valve..."));
// Сначала подтверждаем, что клапан был закрыт, кратковременно заезжая на концевой "закрыто"
setupRelays(1);
if (waitForEndOfCycle(ValveClosedPin) == 0) {
// Теперь пытаемся открыть
setupRelays(2);
if (waitForEndOfCycle(ValveOpenedPin) == 0) {
isConnectedToValve = 1;
isValveClosed = 0;
setupRelays(0); // реле выкл.
Serial.println(F(""));
return 0;
}
}
setupRelays(0);
isConnectedToValve = 0;
return -1;
}
// Закрытие клапана
int closeValve() {
Serial.print(F("Closing valve..."));
// Сначала подтверждаем, что клапан был открыт
setupRelays(2);
if (waitForEndOfCycle(ValveOpenedPin) == 0) {
// Пытаемся закрыть
setupRelays(1);
if (waitForEndOfCycle(ValveClosedPin) == 0) {
isConnectedToValve = 1;
isValveClosed = 1;
setupRelays(0);
Serial.println(F("Valve has been turned off. Please inspect carefully all rooms and cleanup detectors"));
return 0;
}
}
setupRelays(0);
isConnectedToValve = 0;
return -1;
}
// Настройка реле для нужной полярности
void setupRelays(int scenario) {
switch (scenario) {
case 0: // все выкл.
digitalWrite(ValveControl1, HIGH);
digitalWrite(ValveControl2, HIGH);
break;
case 1: // цикл закрытия
digitalWrite(ValveControl1, HIGH);
digitalWrite(ValveControl2, LOW);
break;
case 2: // цикл открытия
digitalWrite(ValveControl1, LOW);
digitalWrite(ValveControl2, HIGH);
break;
default:
Serial.print(F("Unexpected relay scenario: "));
Serial.println(scenario);
digitalWrite(ValveControl1, HIGH);
digitalWrite(ValveControl2, HIGH);
}
}
// Ожидание срабатывания концевого выключателя
int waitForEndOfCycle(int limitSwitchPin) {
int cnt = valveTimeOut;
while (cnt > 0) {
if (getDebouncedValue(limitSwitchPin, 10, 10) == LOW) return 0;
cnt--;
Serial.print(F("."));
delay(1000);
}
Serial.println(F(" - timeout reached while closing valve. Check whether valve is well powered up and cables are connected."));
return -1;
}
// Дебаунс цифрового входа
int getDebouncedValue(int inputPin, int intervalInMs, int requiredConfirmations) {
int confirmations = 1;
int currentValue = digitalRead(inputPin);
while (confirmations <= requiredConfirmations) {
delay(intervalInMs);
if (currentValue == digitalRead(inputPin)) {
confirmations++;
} else {
confirmations = 1;
currentValue = digitalRead(inputPin);
}
}
return currentValue;
}
// Управление LED
void configureLedsWithInitialStates() {
clearLeds();
if (isWaterDetectedRemotely == 1 || isWaterDetected == 1) digitalWrite(AlarmLedPin, HIGH);
if (isConnectedToValve == 0 || isConnectedToBroker == 0) digitalWrite(FaultLedPin, HIGH);
digitalWrite(SystemLedPin, HIGH);
}
void configureLedsWithFinalStates() {
if (isWaterDetectedRemotely == 1) digitalWrite(AlarmLedPin, LOW);
if (isConnectedToBroker == 0) digitalWrite(FaultLedPin, LOW);
if (isValveClosed == 1) digitalWrite(SystemLedPin, LOW);
}
void clearLeds() {
digitalWrite(AlarmLedPin, LOW);
digitalWrite(FaultLedPin, LOW);
digitalWrite(SystemLedPin, LOW);
}
void testLeds() {
clearLeds();
digitalWrite(AlarmLedPin, HIGH); delay(500);
digitalWrite(FaultLedPin, HIGH); delay(500);
digitalWrite(SystemLedPin, HIGH); delay(500);
clearLeds();
}
// MQTT функции
void MQTTBrokerCallback(char* subscribedTopic, byte* payload, unsigned int length) {
Serial.print(F("New message received from MQTT broker. Topic="));
Serial.print(subscribedTopic);
String payloadAsString = (char*)payload;
String realPayload = payloadAsString.substring(0, length);
Serial.print(F(", content="));
Serial.print(realPayload);
if (realPayload.indexOf("WaterDetected") > 0 && realPayload.indexOf(mqttClientLocation) == -1) {
isWaterDetectedRemotely = 1;
}
Serial.println();
}
String buildClientIdentifier() {
String data = mqttClientPrefix; data += "_"; data += mqttClientLocation; data += "_"; data += mqttClientUID;
return data;
}
String buildDeviceStatusTopic() {
String data = mqttClientPrefix; data += "/"; data += mqttClientLocation; data += "/"; data += mqttClientUID; data += "/"; data += mqttClientStatusTopic;
return data;
}
String buildFaultTopic() {
String data = mqttClientPrefix; data += "/"; data += mqttClientLocation; data += "/"; data += mqttClientUID; data += "/"; data += mqttClientFaultTopic;
return data;
}
String buildAnyFaultTopic() {
String data = mqttClientPrefix; data += "/"; data += "+"; data += "/"; data += "+"; data += "/"; data += mqttClientFaultTopic;
return data;
}
String buildDeviceStatusJson() {
String data = "{"; data += "\n";
data += "\"ResetByOperator\": "; data += (int)isResetRequested; data += ","; data += "\n";
data += "\"WaterDetected\": "; data += (int)isWaterDetected; data += ","; data += "\n";
data += "\"ValveClosed\": "; data += (int)isValveClosed; data += ","; data += "\n";
data += "\"ValveDetected\": "; data += (int)isConnectedToValve; data += "\n";
data += "}";
return data;
}
String buildFaultJson() {
String data = "{"; data += "\n";
data += "\"WaterDetected\": "; data += (int)isWaterDetected; data += ","; data += "\n";
data += "\"Location\": "; data += mqttClientLocation; data += "\n";
data += "}";
return data;
}
void publishMsg(char (&topic)[200], char (&payload)[200]) {
if (connectToBroker()) {
if (client.publish(topic, payload)) {
isConnectedToBroker = 1;
Serial.print(F("Message sent to MQTT broker using the following topic "));
Serial.println(topic);
} else {
Serial.print(F("Message NOT sent to MQTT broker using the following topic "));
Serial.println(topic);
isConnectedToBroker = 0;
}
client.loop();
}
}
void publishFault() {
char topicBuffer[200]; buildFaultTopic().toCharArray(topicBuffer, 200);
char payloadBuffer[200]; buildFaultJson().toCharArray(payloadBuffer, 200);
publishMsg(topicBuffer, payloadBuffer);
}
void publishStatus() {
char topicBuffer[200]; buildDeviceStatusTopic().toCharArray(topicBuffer, 200);
char payloadBuffer[200]; buildDeviceStatusJson().toCharArray(payloadBuffer, 200);
publishMsg(topicBuffer, payloadBuffer);
}
void subscribeToRemoteWaterSensors() {
if (connectToBroker()) {
char tempBuffer[200]; buildAnyFaultTopic().toCharArray(tempBuffer, 200);
client.subscribe(tempBuffer);
if (client.subscribe(tempBuffer)) {
isConnectedToBroker = 1;
Serial.print(F("Registred to MQTT broker as a subscriber for the following topic: "));
Serial.println(tempBuffer);
} else {
Serial.println(F("Not registred to MQTT broker as a subscriber"));
isConnectedToBroker = 0;
}
client.loop();
} else {
isConnectedToBroker = 0;
Serial.println(F("Cannot subscribe to any topic since connection to MQTT broker is not established"));
}
}
int connectToBroker() {
Serial.println(F(""));
Serial.print(F("Connecting to network and to MQTT Broker... "));
char tempBuffer[200]; buildClientIdentifier().toCharArray(tempBuffer, 200);
if (client.connect(tempBuffer)) {
Serial.print(F("connected as "));
Serial.println(tempBuffer);
} else {
switch (client.state()) {
case -4: Serial.println(F("MQTT_CONNECTION_TIMEOUT")); break;
case -3: Serial.println(F("MQTT_CONNECTION_LOST")); break;
case -2: Serial.println(F("MQTT_CONNECT_FAILED")); break;
case -1: Serial.println(F("MQTT_DISCONNECTED")); break;
case 0: break;
case 1: Serial.println(F("MQTT_CONNECT_BAD_PROTOCOL")); break;
case 2: Serial.println(F("MQTT_CONNECT_BAD_CLIENT_ID")); break;
case 3: Serial.println(F("MQTT_CONNECT_UNAVAILABLE")); break;
case 4: Serial.println(F("MQTT_CONNECT_BAD_CREDENTIALS")); break;
case 5: Serial.println(F("MQTT_CONNECT_UNAUTHORIZED")); break;
default: Serial.print("failed, rc="); Serial.println(client.state());
}
}
return client.connected();
}
MQTT библиотека (C/C++)
На случай необходимости — библиотека доступна для скачивания.
Схемы

Производственный процесс
- Зажимные клапаны и перистальтические насосы: эталон для абразивных систем молочника
- Мастерство управления датчиками и исполнительными механизмами на Raspberry Pi: акселерометр, сервопривод и потоко…
- Автоматизированная система наполнения и мониторинга бассейна
- Отладка двухъядерной системы Arduino Portenta H7 с Segger J-Link
- Универсальный Android-пульт на Arduino, 1Sheeld и ИК-управлении
- Создание анимации и игр на LCD 16×2 с Arduino UNO
- Умная копилка: управление монетоприёмником на Arduino Nano
- Arduino Power Control Center: N-FET, P-FET, реле и RTC
- Детектор аудиочастот на Arduino: измеряем пик громкости с высокочувствительным модулем
- Управление светодиодом по Bluetooth с Arduino: простой DIY-гид