Умный дом замолчал в субботу утром: датчики не шлют, свет не щёлкается, панель пустая. Плата в шкафу была жива — бортовой светодиод подмигивал, — но что именно случилось, я узнал только достав ноутбук и провод: нет ли Wi-Fi, упал ли брокер на Raspberry или роутер опять перезагружался. Двадцать минут диагностики ради ситуации, которую плата знает в каждый момент сама.
После того раза на корпусе каждой платы поселился статусный RGB-светодиод — одна деталь за копейки, три канала на 9, 10 и 11-м пинах. Язык простой: фиолетовый мигает — плата потеряла сеть; красный горит ровно — сеть есть, брокер молчит; зелёный — всё ок, работаем; белая вспышка на долю секунды — команда дошла. В ту субботу я бы подошёл к шкафу, увидел красный и пошёл бы сразу в сторону Raspberry, минуя роутер и ноутбук.
Отдельно про слово «лента» в заголовке: целую ленту на статус жалко и бессмысленно — индикатору хватает одного пикселя. У меня это одиночный RGB-светодиод с общим катодом, а в проектах с WS2812 — первый пиксель ленты, который зажигается тем же fill_solid из стартового урока FastLED и горит статусом до того, как остальная лента отдана автоматике. Раскраску и смешение каналов я подробно собирал в уроке про RGB-светодиод — здесь тот же приём, но живущий в кооперативном цикле рядом с сетью. Один пиксель честнее и в потреблении: статус работает круглосуточно, одиночный светодиод в дежурке ест десятки миллиампер, лента на тридцать пикселей — амперы, и разница приходит каждый месяц в счёте.
Четыре состояния — четыре цвета
Порядок проверки — половина замысла. Плата отвечает не на вопрос «что сломалось вообще», а «что сломалось раньше по цепочке»: без сети брокер недостижим по определению, поэтому сначала WiFi.status() — если это не WL_CONNECTED, дальше смотреть нечего. Сеть есть — проверяю client.connected(): брокер упал, пароль не тот, порт закрыт. Обе ступени пройдены — зелёный. Получается лестница, и каждый цвет называет конкретную ступень, а не размытое «что-то не так». Обе проверки при этом копеечные: оба вызова читают локальные флаги, сеть не трогают, и лестница пробегает каждый проход loop без таймеров и без очередей.
Ритм — вторая половина языка. Фиолетовый при потере сети мигает с периодом 200 мс: быстрая пульсация читается боковым зрением даже через щель шкафа. Красный горит ровно, потому что мёртвый брокер — это тишина, и мигание тут только шумит. Зелёный я приглушил до 60 из 255: дежурный цвет на полную яркость в спальне превращается в ночник, который сам просится выключить. Белая вспышка живёт 150 мс — не сигнал, а подмигивание: команда пришла, я её взял в работу.
Про честность: client.connected() замечает разрыв не мгновенно. Библиотека считает соединение живым, пока идут пинги — keepalive у PubSubClient по умолчанию 15 секунд, и мёртвый сокет может краснеть с запаздыванием на этот срок. Для индикатора это нормальная цена: авария диагностируется за четверть минуты без единого ноутбука. А тонкости reconnect-цикла, статусы подключения и паузы с backoff — отдельная работа, она живёт в уроке про reconnect с backoff; индикатор лишь показывает, на какой ступени та логика стоит.
| Цвет | Что значит | Что я проверяю дальше |
|---|---|---|
| Фиолетовый, мигает 200 мс | нет Wi-Fi: WiFi.status() не равен WL_CONNECTED | роутер, питание, SSID; плата вернётся сама по reconnect-циклу |
| Красный, горит ровно | сеть есть, client.connected() вернул ложь | брокер на 1883: пароль, порт, жива ли Raspberry |
| Зелёный, приглушённый 60/255 | сеть и брокер на месте | ничего — дежурный режим |
| Белая вспышка 150 мс | в callback упала команда | издателя: шлёт ли HA, тот ли топик |
Разводка на макетке старая, как сам светодиод: общий катод в GND, на каждый канал резистор 220 Ом, ножки каналов — на 9, 10 и 11-й. Пины не случайные: это ШИМ-ножки Uno, и только на них analogWrite даёт промежуточную яркость — статусы на других пинах выродились бы в три тумблера. Резисторов три, а не один на общем проводе: общий резистор делает цвет зависимым от яркости остальных каналов, и белый рассыпается в розовость, когда два канала горят, а третий притушен.
Ток одного канала с 220 Ом — около 15 мА, все три — под 45: пину такие расходы безразличны. Провод до корпуса платы я тяну витой четвёркой под метр и ни разу не пожалел: ШИМ на 490 герцах у пинов 9 и 10 спокойно переживает такую длину. А вот если индикатор переезжает в соседний шкаф — витая пара с землёй в двух жилах или экран: индикатор, который врёт из-за наводок, хуже отсутствующего.
#include <PubSubClient.h> WiFiClient net;PubSubClient client(net);const int R = 9, G = 10, B = 11; unsigned long flashUntil = 0; // белая вспышка действует до этого моментаString lastStatus = ""; void cvet(int r, int g, int b) { analogWrite(R, r); analogWrite(G, g); analogWrite(B, b);} void callback(char* topic, byte* payload, unsigned int length) { flashUntil = millis() + 150; // команда пришла — подмигнуть белым} void setup() { Serial.begin(9600); pinMode(R, OUTPUT); pinMode(G, OUTPUT); pinMode(B, OUTPUT); WiFi.begin("HomeNet", "12345678"); while (WiFi.status() != WL_CONNECTED) { delay(300); } client.setServer("192.168.1.50", 1883); while (!client.connect("esp32-status")) { delay(2000); } client.setCallback(callback); client.subscribe("cmd/home/#");} void loop() { client.loop(); String status = "ok"; if (WiFi.status() != WL_CONNECTED) { status = "net-seti"; } else if (!client.connected()) { status = "net-brokera"; } if (status != lastStatus) { // печать только при смене Serial.print("status: "); Serial.println(status); lastStatus = status; } if (millis() < flashUntil) { cvet(255, 255, 255); } // вспышка поверх всего else if (status == "net-seti") { if ((millis() / 200) % 2 == 0) { cvet(130, 0, 255); } // фиолетовый else { cvet(0, 0, 0); } } else if (status == "net-brokera") { cvet(255, 0, 0); } // красный ровно else { cvet(0, 60, 0); } // дежурный зелёный delay(20);}Мигалка без delay держится на делении времени: millis() / 200 % 2 == 0 — первые 200 мс такта горим, вторые — темнота, весь цикл 400 мс. Если та формула кажется новой, её основы лежат в уроке про millis вместо delay — тут она в самом дешёвом исполнении. Вспышка устроена иначе: callback ничего не рисует, он только продлевает дедлайн flashUntil на 150 мс, а loop на каждом проходе сначала проверяет, жив ли дедлайн, и лишь потом раздаёт цвета. Приоритет получается сам собой: вспышка перекрывает даже аварию, потому что проверяется первым if.
Печать статуса я тоже перевёл на смену, а не на поток: строка выходит только когда статус изменился, и в мониторе вместо сотни одинаковых строк — три за вечер. Это же правило бережёт брокер: индикатор ничего не публикует, он локальный зритель. Для дистанционного взгляда на ту же аварию существует пара retain и Last Will — плата и брокер сами раздают «офлайн» тем, кто в доме. Локальный цвет и удалённый LWT решают разные задачи, и связка из них двоих закрывает почти весь спектр вопросов «а что сейчас с платой» — механику я собирал в уроке про индикатор офлайн.
Симулятор: поломки по команде
Статусы в симуляторе подаются из монитора — и это не упрощение, а честная замена: движок не рвёт сеть по-настоящему, зато WiFi.disconnect() и client.disconnect() переключают те же флаги, которые на реальной плате переключает реальная беда. Погоняй лестницу: выключи сеть при живом брокере — фиолетовый; включи сеть и урони брокер — красный; верни всё — зелёный. Смена статуса приходит в монитор одной строкой на поломку, потому что печатается только изменение.
Прежде чем доверять цвету, я неделю сверял его с Serial-печатью: каждая авария отражалась и в цвете, и в строке, и только когда обе руки сошлись, индикатору поверили. На живой плате к трём цветам стоит добавить четвёртый слой — звук: зуммер на 8-м пине с коротким писком при смене на красный, потому что в шкафу светодиод не видно, а писк слышен из коридора. Сама логика при этом не меняется ни на строку: лестница проверок и millis-тайминги переезжают на любой рендер индикатора как есть.
Мой любимый приёмочный тест после сборки: выдернуть питание роутера на минуту. Хорошая связка reconnect-цикла и индикатора сама отыграет фиолетовый, красный и вернёт зелёный без единого касания, а Serial-строки покажут, сколько секунд заняло возвращение. Если связка не вернулась — это не индикатор сломался, а reconnect-логика: проверяй её по уроку про reconnect с backoff, индикатор лишь честно показал, где цикл застрял. Ниже — живой микшер: подвигайте ползунки R, G и B и соберите свой статусный цвет — duty каждого канала виден на мини-осциллограммах.
Три канала R/G/B смешиваются в цвет — как в скетче analogWrite().
Подгружаем интерактив…
- Лестница проверки: сначала WiFi.status() — сеть, потом client.connected() — брокер, потом зелёный; цвет называет ступень
- Фиолетовый мигает 200 мс, красный горит ровно, зелёный приглушён до 60/255, белая вспышка 150 мс на команду
- client.connected() замечает разрыв с запаздыванием: keepalive у PubSubClient по умолчанию 15 секунд
- Вспышка — дедлайн flashUntil в callback, рисование всегда в loop; вспышка перекрывает аварию, потому что проверяется первым if
- Индикатор локален и молчалив: для удалённого взгляда — retain и Last Will, для шкафа — зуммер
- 1. Какая проверка первая в лестнице статусов?
- 2. Почему красный (брокера нет) горит ровно, а фиолетовый (нет сети) мигает?
- 3. Связь с брокером оборвалась. Через сколько client.connected() гарантированно это заметит?
- 4. Как устроена белая вспышка на команду?
- 5. Что добавит дистанционный взгляд на аварию — тот, что виден с телефона?
Частые вопросы по теме урока
- Какая проверка первая в лестнице статусов?
- Без сети брокер недостижим по определению: сначала сеть, потом брокер. Тестовая публикация для диагностики не нужна и захламляет лог. WiFi.status() != WL_CONNECTED — есть ли сеть
- Почему красный (брокера нет) горит ровно, а фиолетовый (нет сети) мигает?
- Цвет и ритм — два независимых канала языка: быстрое фиолетовое дёрганье видно через щель шкафа, ровный красный не шумит. Оба канала ШИМные, экономия тут ни при чём. ритм — второй различитель: быстрая пульсация читается боковым зрением издалека
- Связь с брокером оборвалась. Через сколько client.connected() гарантированно это заметит?
- Библиотека считает соединение живым, пока идут пинги: мёртвый сокет краснеет с запаздыванием до срока keepalive, по умолчанию 15 с. не мгновенно — до 15 секунд: столько по умолчанию держит keepalive у PubSubClient
- Как устроена белая вспышка на команду?
- Рисование всегда в loop: callback только продлевает дедлайн. delay в callback рвёт keepalive, прерывание здесь избыточно. callback продлевает дедлайн flashUntil = millis() + 150, а loop рисует белый, пока дедлайн жив
- Что добавит дистанционный взгляд на аварию — тот, что виден с телефона?
- Индикатор на корпусе локален: его видно только у платы. Удалённо состояние отдают retain и LWT — об этом был отдельный урок. retain-статус и Last Will: плата и брокер сами публикуют офлайн