Пульт насосной станции в подвале я довёл до ума ещё в прошлом сезоне: экран показывает уровень, счётчик циклов, время работы. А потом роутер перезагрузили, MQTT-связь умерла, и экран ещё четыре часа честно рисовал старый уровень, как ни в чём не бывало. Прибор не врал — он просто не показывал, что связь умерла. Лечение заняло вечер: в первой строке появилось постоянное поле статуса — антенна и три знака OK — и с тех пор обрыв виден через секунду, из другого конца подвала, краем глаза.
Сетевая половина истории у меня уже написана: подключение по Ethernet и MQTT — в уроке про W5500-клиента, машина состояний связи — в уроке про состояния подключения, переподключение с паузами и потолком — в уроке про бэкофф. Здесь — интерфейсная половина: куда на экране ставить статус, чтобы он читался, как рисовать мигание при переподключении и почему печать статуса каждый проход loop — идея, которая убивает и экран, и шину.
План такой: символ-антенна и поле фиксированной ширины, машина состояний OK-OFF-RC на millis, мигание в окне 400 мс и дисциплина обновлений — статус только при смене, данные по тику. В симуляторе сеть не рисовать, поэтому кнопка макетки изображает обрыв, светодиод — связь, а машина состояний и мигание — настоящие, из скетча урока.
Антенна и поле статуса: почему первым символом
Статус живёт первым символом первой строки не из каприза, а из физиологии чтения экрана. Глаз человека сканирует слева направо, и первый знак строки ловится периферийным зрением без «чтения» вообще: вы проходите мимо прибора и просто видите, что слева мигает или что слева пусто. Полноценный индикатор — это стоп-сигнал, а не абзац текста. Поэтому статусу отводится постоянное место, одно и то же, и никаких переносов: поле в 5-6 знаков — антенна, два-три знака состояния, номер попытки, — и всё, что правее, уже данные.
Антенна рисуется своим глифом в знакогенераторе: слот седьмой, последний свободный, мачта из штрихов и две дуги сверху, пять на восемь пикселей. Механика createChar с байтовыми массивами разобрана по шагам в уроке про свои символы, повторять не буду. Единственная дисциплина: слоты CGRAM дефицитны, и статусная строка не должна съедать их больше одного — антенны достаточно, состояние она сообщает словами OK, OFF, RC в соседних знакоместах.
Данные рисуются после поля статуса, и это не только про порядок, но и про ширину. Числа — в поле фиксированной ширины с ведущими пробелами, иначе от «5 мин» до «105 мин» цифры елозят и глаз не за что цепляется, вся механика пробелов разобрана в уроке про числа и нули. На 16x2 у меня сложилось так: первая строка — статус и счётчик пакетов, вторая — текущие данные; на 20x4 статус уезжает в первые знакоместа верхней строки, а всё остальное живёт своей жизнью. Главное — статус никогда не двигается с места.
Отдельно про данные при обрыве, потому что это половина дизайна статусной строки. Кадр данных остаётся на второй строке как был, но при OFF я помечаю его старым: прочерки в поле счётчика или слово «старое» в углу — читатель должен знать, что цифры заморожены. Стирать их нельзя: последний уровень всё равно полезен; врать нельзя: свежестью уже не пахнет; пометка занимает ровно одну ячейку и решает обе проблемы. На брокере ту же роль играет retained-сообщение, и когда оба механизма на месте, ложных показаний не остаётся нигде.
#include <Wire.h>#include <LiquidCrystal_I2C.h> LiquidCrystal_I2C lcd(0x27, 16, 2);const int btnObryv = 2; // кнопка: имитация обрыва связиconst int ledNet = 12; // светодиод связи // Состояния связи: 0 - нет, 1 - переподключаемся, 2 - естьint sost = 2;int popytka = 0;unsigned long tSmeny = 0; // момент смены состоянияunsigned long tPopytki = 0; // момент последней попыткиunsigned long tPaket = 0; // тик счётчика пакетовlong pakety = 0;bool prevBtn = HIGH; void setStatus(int nov) { // печать статуса ТОЛЬКО при смене if (nov == sost) return; sost = nov; popytka = 0; tSmeny = millis(); lcd.setCursor(1, 0); if (sost == 2) { lcd.print("OK "); digitalWrite(ledNet, HIGH); } if (sost == 0) { lcd.print("OFF "); digitalWrite(ledNet, LOW); lcd.setCursor(6, 0); // заморозить счётчик пакетов lcd.print("------"); } if (sost == 1) { lcd.print("RC "); tPopytki = millis(); // первая попытка через 2 с }} void loop() { bool b = digitalRead(btnObryv); if (b == LOW && prevBtn == HIGH) setStatus(0); prevBtn = b; // нет связи: через 2,5 с начинаем попытки, шаг 2 с if (sost == 0 && millis() - tSmeny > 2500) setStatus(1); // переподключаемся: попытка каждые 2 с, успех с третьей if (sost == 1 && millis() - tPopytki > 2000 && millis() - tSmeny > 2000) { tPopytki = millis(); popytka++; lcd.setCursor(3, 0); lcd.print(popytka); if (popytka >= 3) setStatus(2); } // мигание поля RC: окно 400 мс, меняется только поле if (sost == 1) { int okno = (millis() / 400) % 2; lcd.setCursor(1, 0); if (okno == 0) lcd.print("R"); else lcd.print(" "); } // связь есть: счётчик пакетов тикает раз в секунду if (sost == 2 && millis() - tPaket > 1000) { tPaket = millis(); pakety++; lcd.setCursor(6, 0); lcd.print("P:"); lcd.print(pakety); lcd.print(" "); }}| Состояние | Что на экране | Светодиод и данные |
|---|---|---|
| OK | Антенна, OK, счётчик пакетов тикает | Диод горит, данные свежие |
| OFF | Антенна, OFF, счётчик заморожен прочерками | Диод погас, данные старые |
| RC 2 | Поле мигает в окне 400 мс, номер попытки растёт | Диод мигает вместе с полем |
| Отложено | Данные помечены как старые — буфер не ушёл | Диод горит вполнакала, если есть ШИМ |
Мигание в millis-окне и честный источник правды
Окно мигания считается одной строкой: (millis() / 400) % 2 — чётные окна рисуем символ, нечётные пробел. Четыреста миллисекунд — не случайность: частота около двух герц глаз однозначно читает как тревогу, быстрее — поле сливается в полутон, медленнее — теряется ощущение «что-то происходит». Между вспышками меньше сотни миллисекунд глаз уже не различает, поэтому окно короче четырёхсот превращает тревогу в дрожь. Важна и скромность: мигает одна ячейка статуса, а не строка и не весь экран — кадр не трогается, шина молчит между сменами, и мигание видно ровно там, где надо. База millis-логики без единого delay уже лежит в скетче ниже: tSmeny, tPopytki, tPaket — три независимых таймера, и ни один из них не задерживает loop.
Внутри поля RC полезен номер попытки: RC1, RC2, RC3. Он делает две вещи сразу — показывает, что прибор борется, а не завис, и делает бэкофф наглядным: попытки идут через 2, 4, 8 секунд с потолком, и по номеру видно, какая попытка сейчас идёт. Успех — по факту, а не по времени: после CONNACK от брокера статус перещёлкивается в OK, счётчик попыток обнуляется, поле перестаёт мигать. Источник правды о связи на плате — клиент и сеть: client.connected() у MQTT или WiFi.status() у ESP32, как разобрано в уроке про автовозобновление WiFi, но не собственная переменная-надежда, которую никто не обновляет.
Отладка всей машины без сети — приём, который сэкономил мне не один вечер. Кнопка-обрыв из скетча делает интерфейс статуса проверяемым до того, как вы дойдёте до брокера: нажали — OFF и замерший счётчик, подождали — мигающее RC с растущим номером, дождались — OK и тикающие пакеты. Сначала добейтесь идеального сценария на макетке и только потом подключайте настоящий сетевой клиент: смена состояний у него будет та же, поменяется лишь триггер. Есть и вторая сторона той же истории: брокер должен знать, что узел умер, — для этого служит завещание LWT, и с серверной стороны разрыв виден в панели, разобранной в уроке про LWT-индикатор. Экран и завещание дополняют друг друга: экран говорит человеку у прибора, завещание — всем остальным, кто смотрит на дашборд. И последнее: заведите счётчик разрывов за сутки — одно число в углу экрана скажет о качестве сети больше, чем любые графики, а пишется оно в EEPROM одной строкой update при каждой смене OFF-OK.
Что рисовать, когда всё хорошо, — вопрос, который задаётся реже, а зря. Счётчик пакетов из скетча — минимум, и он уже полезен: замерший счётчик при живом статусе виден сразу. Второй уровень — время с последнего сообщения, третий — адрес брокера или имя сети в момент настройки. А вот сыпать на статусную строку всё подряд не надо: она отвечает на единственный вопрос — жива ли связь. Всё остальное — для второй строки или соседнего экрана, иначе первое знакоместо перестанет читаться краем глаза и смысл поля умрёт.
Последнее про железо, а не софт: на плате, где экран ходит по I2C, статусная строка почти ничего не стоит — пара десятков байт раз в секунду, шина даже не заметит. Но если прибор батарейный и экран засыпает, статус — последний, что стоит гасить: он и есть причина, по которой человек подходит к прибору. Сон подсветки и её пробуждение по активности разобраны отдельно, а здесь правило простое: пока есть связь или борется за неё — поле статуса горит, и только при полном сне прибора можно погасить и его.
Если пересобирать коротко, чек-лист статусной строки такой: антенна в последнем слоте знакогенератора, поле состояния пятью знаками с первого столбца, номер попытки рядом с RC, мигание окном 400 миллисекунд и только одной ячейкой, данные по тику в секунду в поле фиксированной ширины, счётчик разрывов за сутки в EEPROM. Шесть пунктов на вечер работы — и прибор с этого момента умеет главное: говорить правду о себе даже тогда, когда сказать по существу ему нечего. Именно этот талант отличает прибор от табло.
- 1. Почему поле статуса занимает первый символ строки, а не где-то в центре?
- 2. Что ломается, если печатать статус в каждом проходе loop?
- 3. Каким окном мигать поле RC, чтобы глаз читал его как тревогу?
- 4. Зачем в поле RC показывать номер попытки?
- 5. Что на плате служит источником правды о связи?
- Статус — первым символом первой строки: один знак читается краем глаза, место никогда не меняется
- Поле фиксированной ширины: антенна в слоте 7, OK/OFF/RC словами, данные правее в пробелах
- Печать только при смене: статус — по событию, данные — по тику, мигание — одна ячейка в окне 400 мс
- Бэкофф попыток 2-4-8 секунд, успех по факту CONNACK, источник правды — client.connected()/WiFi.status()
- Счётчик разрывов за сутки в EEPROM — честный индикатор качества сети одним числом
Частые вопросы по теме урока
- Почему поле статуса занимает первый символ строки, а не где-то в центре?
- Статус — стоп-сигнал: он должен читаться краем глаза, издалека, без разбора букв. Отсюда постоянное место слева и фиксированная ширина поля. Первый знак ловится периферийным зрением без чтения строки
- Что ломается, если печатать статус в каждом проходе loop?
- Кадр по I2C стоит 10-15 мс; сотни кадров в секунду забивают шину. Печатать надо только при смене состояния или по тику. Шина занята 10-15 мс на кадр постоянно: дрожание и тормозят соседи
- Каким окном мигать поле RC, чтобы глаз читал его как тревогу?
- (millis()/400)%2 даёт два герца — глаз читает это как тревогу. Мигает одна ячейка, кадр не трогается. 400 мс — около двух герц
- Зачем в поле RC показывать номер попытки?
- RC1, RC2, RC3 делают бэкофф наглядным: прибор борется, а не завис, и видно, какая попытка идёт. Видно борьбу прибора и темп бэкоффа: попытки идут через 2, 4, 8 секунд
- Что на плате служит источником правды о связи?
- Статус перещёлкивается по факту от сетевого клиента: connected() у MQTT, WiFi.status() у ESP32. Надежда — не источник данных. client.connected() или WiFi.status(), а не надежда