Квитанция за воду в один месяц выросла вдвое, и я неделю гадал, где течёт: счётчик стоял в шкафу за трубами, показания я снимал раз в месяц по фотографии на телефон. Желание было простое: чтобы литры сами стекались в дашборд, как температура из теплицы, а счётчик при этом оставался пломбированным и нетронутым. Выход оказался на поверхности: у многих бытовых счётчиков магнит счётного механизма ходит прямо под задней стенкой, и геркон, приклеенный снаружи на корпус, щёлкает на каждый оборот, не нарушая ни пломбы, ни водопровода.
Этот урок открывает маленькую трилогию про учёт и восстановление. Здесь соединяем счётчик с ESP32: два провода геркона, антидребезг на 50 миллисекунд, переменная с литрами и публикация раз в минуту документом вида {"liters": 417}. Во втором уроке займёмся тем, что счёт в переменной умирает вместе с питанием, а в третьем добавим к MQTT ещё и веб-панель на самой плате.
Кнопка на макетке изображает геркон: в симуляторе щелчок кнопки — это проход магнита мимо контакта, светодиод мигает как на настоящем узле, а в мониторе видно, как из импульсов собирается итог. На реальной плате вместо кнопки — два провода от геркона, и код не меняется ни на символ.
Геркон: контакт, который щёлкает на магнит
Геркон — пара пружинящих контактов в стеклянной колбе, замыкающаяся, когда рядом магнит. В счётчике воды магнит вращается вместе со счётным механизмом, и каждый оборот — один щелчок. Провода от геркона не касаются воды и не требуют вскрытия: контакт клеят снаружи на корпус напротив последней цифры шкалы, обычно на двусторонний скотч. Электрика элементарная: один провод на D2, второй на GND, pinMode с INPUT_PULLUP — внутренняя подтяжка держит вход в единице, а замкнувшийся геркон роняет его в ноль.
Множитель «импульс-литры» задан производителем, и он у моделей разный: где-то один щелчок приходится на литр, у многих бытовых счётчиков — на десять, у части узлов учёта и на сто. Проверяется это ведром: выливаете ровно десять литров, считаете щелчки, делите. Так я узнал, что мой счётчик щёлкает раз на десять литров, — до этого неделя смотрела на график, где расход почему-то выходил в десять раз скромнее реального.
Дребезг у геркона такой же, как у кнопки: контакты в колбе пружинят, и один щелчок на осциллографе выглядит как серия из трёх-пяти фронтов за первые несколько миллисекунд. Без фильтрации один проход магнита посчитается дважды, и счёт поплывёт вверх. Лечится ровно тем, чем мы лечили кнопку в уроке про дребезг: состояние считается истинным только после того, как продержалось окно в 50 миллисекунд.
Два провода геркона — это просто контакт: никакого напряжения со счётчика не приходит, пин видит лишь замыкание на землю, так что вечная тревога про пять вольт против трёх с половиной здесь не существует вовсе. Длинная линия — другая история: если провод тянется дальше пары метров мимо блока питания или сети 220 В, скрутите его в пару или берите экранированный — участок рядом с силовыми кабелями ловит наводки, и вход с подтяжкой начинает давать фантомные срабатывания. У меня до счётчика четыре метра жилы из витой пары вдали от силовых пучков — за три года ни одного фантома.
Модули геркона с платой-компаратором (KY-025 и похожие) выдают готовый цифровой уровень и подстроечный потенциометр — с ними порог срабатывания можно покрутить, но голый геркон честнее и дешевле: один контакт, нечему ломаться, нечего настраивать. Для счёта воды этого достаточно, а лишняя электроника между счётчиком и пином — это ещё одно место, где однажды пропадёт контакт.
#include <WiFi.h>#include <PubSubClient.h>#include <ArduinoJson.h> const int REED = 2; // геркон: замкнут = LOW (INPUT_PULLUP)const int LED = 12; // вспышка на каждый импульсconst int LITERS_PER_PULSE = 1; // у моей модели; бывает 10 и 100const unsigned long DEBOUNCE = 50; // окно антидребезга, мсconst unsigned long PUB_PERIOD = 60000; // публикация раз в минуту WiFiClient net;PubSubClient client(net);DynamicJsonDocument doc(192);int liters = 0;int raw = HIGH, stable = HIGH;unsigned long lastMove = 0, lastPub = 0, ledUntil = 0; void setup() { Serial.begin(115200); pinMode(REED, INPUT_PULLUP); pinMode(LED, OUTPUT); WiFi.begin("HomeNet", "12345678"); while (WiFi.status() != WL_CONNECTED) delay(300); client.setServer("192.168.1.50", 1883); client.connect("dacha-water");} void loop() { client.loop(); unsigned long ms = millis(); int reading = digitalRead(REED); if (reading != raw) { raw = reading; lastMove = ms; } if (ms - lastMove > DEBOUNCE && reading != stable) { stable = reading; if (stable == LOW) { // геркон замкнулся — импульс liters = liters + LITERS_PER_PULSE; ledUntil = ms + 80; // светодиод мигнул Serial.print("impuls, litrov: "); Serial.println(liters); } } digitalWrite(LED, ms < ledUntil ? HIGH : LOW); if (ms - lastPub >= PUB_PERIOD) { lastPub = ms; doc["liters"] = liters; String out = ""; serializeJson(doc, out); client.publish("home/water/total", out.c_str(), true); // retain: итог у брокера Serial.print("publish "); Serial.println(out); }}Счёт стоит на фронте, а не на уровне: считаем только переход единицы в ноль подтверждённого состояния — замыкание геркона. Уровнем считать нельзя: пока магнит стоит рядом, контакт замкнут, и каждый проход loop() насчитал бы новый импульс. Окно в 50 миллисекунд я проверил на своём напоре: даже с полностью открытым краном щелчки идут реже раза в секунду, а у счётчика с десятью литрами на импульс — раз в несколько секунд, так что честный импульс окно не съест, а дребезг съедает целиком.
Светодиод на D12 — не украшение: при монтаже на реальном счётчике он экономит час работы. Магнит ходит под корпусом не там, где кажется на глаз, и с первого раза геркон часто клеится мимо зоны срабатывания; пока светодиод не мигнул на открытом кране, проводку в короб прятать рано. Форма вспышки — отсечка ledUntil по millis без единого delay: блокировка в главном цикле сломала бы и антидребезг, и клиент брокера, что разобрано в уроке про millis вместо delay.
Есть и второй режим — публикация по импульсу: каждый щелчок сразу уходит в брокер как событие. При множителе десять литров и суточном расходе в триста литров это тридцать сообщений в сутки — брокер их не заметит, зато дашборд видит расход вживую, без минутного запаздывания. Я остановился на минутных итогах: ступеньки накопления рисуются ровнее, а события импульсов мне нужны только при отладке монтажа, когда светодиода мало.
Публикация раз в минуту — это 1440 сообщений в сутки, по пятьдесят-шестьдесят байтов на пакет: меньше сотни килобайтов трафика в сутки, для брокера на малине шум. Зачем минута, если итог меняется реже: дашборд видит живую систему, и тишина дольше пары минут сама по себе симптом пропавшего узла. А тройка литры-топик-retain образует мостик к следующему уроку: у брокера всегда лежит последний итог, и после рестарта платы ничего не потеряется. Про выбор периода подробнее — в уроке про публикацию показаний, про формат документа — в уроке про JSON-payload.
Между импульсами обслуживание брокера стоит копейки, но client.loop() обязан крутиться постоянно: публикация одного документа через ArduinoJson занимает миллисекунды, и за это время связь не успевает ничего заметить, а вот длинная пауза в цикле — успевает. Пятнадцать секунд окна keepalive и механика пинга разобраны в уроке про keepalive — счётчик воды не отменяет этого правила ни на байт.
Счёт в переменной, итог — брокеру
Счёт в переменной стартует с нуля при каждой прошивке, а счётчик на трубе — со своих кубов. Пути примирить их два: завести константу OFFSET с показанием на день прошивки или вообще не сопоставлять, потому что для графиков важна динамика, а не абсолют. Я выбрал второе, а начальное значение восстановил из брокера: retained-итог в home/water/total пережил и перезагрузки, и переезды скетча — механика этого восстановления и есть тема второго урока трилогии.
Локальное хранение итога между включениями — отдельная история: EEPROM на плате переживает питание, но свою цену за перезаписи платит, и разбирать её здесь не буду — готовый образец уже стоит в уроке про счётчик включений в EEPROM. Про имя топика скажу коротко: home/water/total собран по схеме дом-комната-величина, чтобы узлы не путались между собой, — вся логика имён разобрана в уроке про структуру топиков.
Отдельная ценность минутных итогов — производные. Разность соседних значений даёт расход литров в минуту: открыли кран — график подпрыгнул, перекрыли — упал к нулю. При полном напоре мой кухонный кран даёт десять-двенадцать литров в минуту, и такая ступенька на графике узнаётся мгновенно. Семья из трёх человек тратит триста-четыреста литров в сутки; если сутки показывает тысячу, пора искать протечку, и график расхода укажет на неё быстрее, чем квитанция через месяц.
| Ситуация | Как считаем | Почему так |
|---|---|---|
| Счётчик 1 импульс на литр, кран открыт | фронт LOW плюс окно 50 мс | щелчки реже раза в секунду — окно не съест честного импульса |
| Счётчик 1 импульс на 10 литров | та же схема, LITERS_PER_PULSE = 10 | множитель живёт в одной константе, код не меняется |
| Телеметрия узла | итог раз в минуту, 1440 сообщений в сутки | поток импульсов в брокер не тянет: итог вместо событий |
| Питание пропало | RAM обнулилась, итог вернётся из retained | тема второго урока кластера |
- Поставьте LPP равным 10 и пощёлкайте кнопку: счёт и публикуемый итог меняются вместе, потому что множитель живёт в одном месте.
- Сожмите окно антидребезга до 5 миллисекунд и щёлкайте кнопкой быстро: часть нажатий посчитается дважды — это и есть дребезг, который окно в 50 миллисекунд съедало.
- Добавьте публикацию home/water/flow с литрами за последнюю минуту: разность двух соседних итогов уже лежит в скетче, нужна вторая переменная lastLiters.
- Уберите третий аргумент true из publish и представьте рестарт брокера: последний итог исчезнет. С retain он останется на месте — этой разницей занимается следующий урок.
- Геркон клеится снаружи на корпус счётчика: два провода, INPUT_PULLUP, пломба целая
- Считаем фронт замыкания, а не уровень; окно антидребезга 50 мс по millis
- Множитель литров на импульс задан производителем — проверяется ведром и живёт в одной константе
- Счёт — в переменной, брокеру уходит итог раз в минуту документом {"liters": N} с retain
- Светодиодная вспышка на каждый импульс — инструмент монтажа, а не украшение
- 1. Почему импульсы считают по фронту, а не по уровню сигнала?
- 2. Сколько сообщений в сутки уходит в брокер при публикации раз в минуту?
- 3. Счётчик даёт один импульс на десять литров. Где менять код?
- 4. Зачем светодиоду вспышка через отсечку ledUntil, а не delay(80)?
- 5. Питание платы пропало на час. Что происходит со счётом литров?
Частые вопросы по теме урока
- Почему импульсы считают по фронту, а не по уровню сигнала?
- Замкнутый контакт держит ноль, пока магнит рядом: счёт по уровню насчитал бы тысячи импульсов за одно замыкание. Импульс — переход единицы в ноль подтверждённого состояния. Пока геркон замкнут, уровень даст новый импульс на каждом проходе loop
- Сколько сообщений в сутки уходит в брокер при публикации раз в минуту?
- В сутках 1440 минут — столько же сообщений. По пятьдесят-шестьдесят байтов на пакет это меньше сотни килобайтов в сутки: для брокера на Raspberry это шум. 1440
- Счётчик даёт один импульс на десять литров. Где менять код?
- Множитель живёт в одном месте, счёт и публикуемый итог меняются вместе. Окно антидребезга и период публикации от модели счётчика не зависят. В одной константе LITERS_PER_PULSE
- Зачем светодиоду вспышка через отсечку ledUntil, а не delay(80)?
- Любой delay в главном цикле глушит обслуживание связи и фильтр дребезга. Отсечка по millis даёт вспышку, которая ничего не блокирует. delay заморозил бы цикл: антидребезг и client.loop() встали бы на 80 мс
- Питание платы пропало на час. Что происходит со счётом литров?
- RAM умирает вместе с питанием: переменная стартует с нуля. Но последний итог с retain лежит у брокера, и после рестарта его можно забрать — механика в следующем уроке. EEPROM в скетче не использовался. Счёт в переменной обнулился: он лежал в RAM