В квартире пять узлов, каждый раз в десять секунд пишет своё home/<комната>/state: температура, влажность, состояние двери. Монитор порта агрегатора превращается в поток из тридцати строк в минуту, а ответ на вопрос «что у нас сейчас» требует листать историю глазами. Мой выход из этого потока — отдельная роль: узел-агрегатор, который подписан одним шаблоном home/+/state, держит в памяти последние значения всех узлов и по команде печатает одну строку: kitchen 22.4 | bedroom 19.8 | door closed. Одна строка вместо пяти окон — и внезапно вся картина дома видна целиком.
Шаблоны подписки и то, что плюс матчит ровно один уровень, разобраны в уроке про wildcard плюс и решётку — здесь берём это за исходную точку и строим над шаблоном хранилище. План такой: вытащить имя узла из строки топика, разложить значения по слотам, помечать свежесть и уметь печатать сводку тремя способами — на каждое сообщение, по таймеру и по запросу.
Почему агрегатор — отдельное устройство, а не ещё один экран панели? Панель показывает то, что есть сейчас, и не обязана помнить историю. Агрегатор же держит состояние дома в памяти между сообщениями, умеет замечать тишину и решать, кому гореть светодиодом, а кому печатать old. Такой узел удобно иметь один на квартиру: он один ходит в брокер с широкой подпиской, а остальной софт читает его сводку — или те же статусы напрямую, если решите, что агрегатор нужен только для диагностики.
Из топика — имя узла: разбор сегментов
В сообщении home/kitchen/state агрегатору нужна только середина — kitchen. Классический разбор по слэшам работает, но у String-версии indexOf с начальной позицией на разных ядрах ведёт себя по-разному, поэтому я режу проще: откусываю фикс пять символов home/ через substring(5), в остатке ищу первый слэш — всё до него имя узла, всё после обязано быть /state. Хвост проверяю жёстко: home/kitchen/temp в агрегатор не попадёт, хотя подписка home/+/state его бы и не принесла, — двойная проверка бесплатна и спасает при смене шаблона подписки в будущем.
Слоты храню параллельными массивами: имена узлов, последние значения, millis последнего приёма и флаг «видели ли узел вообще». Так на Uno живёт до десятка узлов без динамической памяти: десять имён по двенадцать символов, десяток float и long — десятки байт, никакой кучи. Новое имя занимает первый свободный слот, поиск по кругу. Это то же хранилище последнего значения, что у брокера с retain, только живёт оно у вас в RAM: почему retain выручает панель после рестарта, показано в уроке про retained-статус.
Цифры по памяти для спокойствия: четыре узла — это четыре String по десятку символов, четыре float и четыре отметки времени. Итого меньше ста байтов со служебными полями. Десяток узлов на Uno тоже уместится, а дальше начинается плата за удобство: каждая строка в String тянет аллокацию, и на двух килобайтах кучи частые переименования узлов бьют больно. Если узлы фиксированы и известны заранее, имена можно вынести в массив const char* — тогда память расходуется ровно по числу символов, и фрагментации нет.
#include <WiFi.h>#include <PubSubClient.h> WiFiClient net;PubSubClient client(net); const int LED_ALL_FRESH = 12; // горит, пока все узлы живыеconst int N = 4;const unsigned long STALE = 90000; // 90 секунд без вестей = старое значение String node[N];float val[N];unsigned long seen[N];bool known[N]; int slotOf(String topic) { // home/kitchen/state -> kitchen if (!topic.startsWith("home/")) return -1; String rest = topic.substring(5); int p = rest.indexOf("/"); if (p < 0) return -1; if (rest.substring(p) != "/state") return -1; String name = rest.substring(0, p); for (int i = 0; i < N; i++) { if (known[i] && node[i] == name) return i; } for (int i = 0; i < N; i++) { if (!known[i]) { node[i] = name; known[i] = true; return i; } } return -1; // слоты кончились: увеличьте N} void printSummary() { unsigned long ms = millis(); Serial.print("svodka | "); bool allFresh = true; for (int i = 0; i < N; i++) { if (!known[i]) { Serial.print("--- | "); allFresh = false; continue; } if (ms - seen[i] >= STALE) { allFresh = false; Serial.print(node[i]); Serial.print(" old | "); } else { Serial.print(node[i]); Serial.print(" "); Serial.print(val[i]); Serial.print(" | "); } } if (allFresh) Serial.println("vse svezhee"); else Serial.println("est zastarevshie");} void callback(char* topic, byte* payload, unsigned int length) { int i = slotOf(String(topic)); if (i < 0) return; char buf[12]; unsigned int n = length < sizeof(buf) - 1 ? length : sizeof(buf) - 1; memcpy(buf, payload, n); buf[n] = 0; float v = String(buf).toFloat(); // не число -> 0: фильтруем ниже if (v == 0 && String(buf) != "0") return; val[i] = v; seen[i] = millis(); printSummary(); // вариант "сводка на каждое сообщение"} void setup() { Serial.begin(115200); pinMode(LED_ALL_FRESH, OUTPUT); for (int i = 0; i < N; i++) known[i] = false; WiFi.begin("HomeNet", "12345678"); while (WiFi.status() != WL_CONNECTED) delay(300); client.setServer("192.168.1.50", 1883); client.setCallback(callback); client.subscribe("home/+/state");} void loop() { if (!client.connected()) { if (client.connect("agregator-01")) client.subscribe("home/+/state"); } client.loop(); static unsigned long lastSum = 0; // вариант "сводка по таймеру" if (millis() - lastSum >= 30000) { lastSum = millis(); printSummary(); } bool allFresh = true; for (int i = 0; i < N; i++) { if (!known[i] || millis() - seen[i] >= STALE) allFresh = false; } digitalWrite(LED_ALL_FRESH, allFresh ? HIGH : LOW);}Свежесть и три способа печатать сводку
Последнее значение без метки времени врёт так же уверенно, как его отсутствие: узел мог умереть час назад, а в слоте лежит его свежий на тот момент 22.4. Поэтому вместе со значением храню seen — millis приёма, и всё, что старше девяноста секунд, печатаю с пометкой old. Порог привязан к периоду публикации узлов: при отправке раз в десять секунд три пропуска подряд — уже подозрение, а 90 секунд дают запас на два-три сбоя Wi-Fi. Сами таймеры публикации и разницу между delay и millis-планировщиком я разбирал в уроке про millis вместо delay — здесь она же, только потребитель другая.
Порог свежести лучше вычислять, а не угадывать: период публикации узла умножить на три. При отправке раз в десять секунд это тридцать секунд, при раз в минуту — три минуты. Девяносто секунд в скетче выше — компромисс для разнородных узлов: температура ходит часто, дверь рапортует редко, и для неё old будет врать. Выход — хранить порог рядом с узлом в отдельном массиве: ещё четыре отметки времени, зато сводка перестаёт наказывать редкие статусы.
Три способа печати в скетче выше живут одновременно, и это не избыточность. Сводка на каждое сообщение удобна при отладке и раздражает в бою: пять узлов умножить на десять секунд — тридцать строк в минуту. Таймер на тридцать секунд — фон для занятого человека. Запрос командой sum в служебный топик — моментальный снимок, когда хочется взглянуть прямо сейчас; для этого агрегатору нужна ещё подписка на home/agr/cmd и ветка в диспетчере из первого урока про маршрутизацию. Мой боевой выбор: таймер плюс запрос, печать на каждое сообщение выключаю через флаг. И светодиод D12 в скетче не украшение: он горит, пока все известные узлы присылают свежие значения, — индикатор дома целиком на одном пине.
| Стратегия сводки | Когда уместна | Цена |
|---|---|---|
| Печать на каждое входящее сообщение | отладка, первые часы после запуска | 30+ строк в минуту при 5 узлах и периоде 10 с |
| Таймер: раз в 30-60 секунд | фоновый дежурный режим на экране или в лог | между печатями событие можно пропустить |
| По запросу sum в командный топик | моментальный снимок по требованию | нужен второй подписанный топик и ветка диспетчера |
| Только при изменении значения | редкие события: двери, утечки | сводка молчит, пока всё хорошо, - нужен отдельный пульс |
Что происходит с хранилищем после рестарта агрегатора? Оно пустое: все слоты сброшены, сводка печатает прочерки, пока узлы не пришлют по новому сообщению — при периоде в десять секунд картина собирается за те же десять секунд, но при редких статусах (дверь рапортует только при событии) прочерчивание затянется. Лекарство уже есть в экосистеме: пусть узлы публикуют статусы с retain, тогда брокер отдаст агрегатору все последние значения в первую секунду после подписки — ещё до вашего первого client.loop(). Требование к соседям одно: статус — это обязательно retain-публикация, иначе агрегатор после каждого рестарта слепнет. Заодно держите в голове, что агрегатор — обычный подписчик: он врёт ровно так, как врут его источники, и лечится это меткой времени в payload, как в уроке про публикацию по millis.
Агрегатор сам по себе тоже узел со статусом, и о его здоровье надо сообщать. Я публикую с него раз в минуту два числа: сколько узлов известно и все ли свежие — в топик home/agr/state. Панель, подписанная на этот топик, видит, что сводка вообще живёт: без пульса мёртвый агрегатор выглядит как экран с честными, но навсегда застывшими числами. При переподключении не забывайте восстанавливать подписку home/+/state — брокер не хранит её для нового подключения, а паузы между попытками у меня на millis с растущим интервалом, как в уроке про reconnect.
И сводка — не единственный выход. Ту же структуру слотов удобно кормить в Serial для отладки, на LCD для стены и в отдельный JSON-топик для панели, если она хочет парсить. Хранилище одно, представления три — случай, когда разбор и печать разделены правильно: slotOf() и массивы ничего не знают о том, кто и куда их напечатает.
- 1. Шаблон home/+/state — какие топики он поймает?
- 2. Зачем агрегатору хранить millis приёма каждого значения?
- 3. Агрегатор перезапустился, статусы соседей публикуются с retain. Что он увидит?
- 4. В aggregator пришёл home/kitchen/temp. Что сделает разбор из урока?
- 5. Печать сводки на каждое входящее сообщение при пяти узлах и периоде 10 секунд — это…
- Подписка home/+/state ловит ровно один уровень: имя узла вырезается substring-ом между слэшами, хвост проверяется на /state
- Хранилище — параллельные массивы: имя, значение, millis приёма, флаг знакомства; на Uno живёт десяток узлов без кучи
- Свежесть по порогу (у меня 90 с при периоде узлов 10 с): старьё печатается с пометкой old, а не выдаётся за правду
- Три способа печати: на сообщение для отладки, по таймеру для дежурки, по запросу для снимка
- retain на статусах соседей собирает сводку после рестарта агрегатора за одну секунду
Частые вопросы по теме урока
- Шаблон home/+/state — какие топики он поймает?
- Плюс матчит ровно один уровень иерархии. home/kitchen/state/temp содержит два уровня после home, поэтому под шаблон не попадает — для этого нужна решётка home/#. home/kitchen/state, но не home/kitchen/state/temp
- Зачем агрегатору хранить millis приёма каждого значения?
- Последнее значение без метки времени врёт: узел мог умереть час назад. Возраст значения — разность millis и метки приёма; всё старше порога печатается с пометкой old. Чтобы отличить свежее значение от устаревшего и пометить old в сводке
- Агрегатор перезапустился, статусы соседей публикуются с retain. Что он увидит?
- Брокер отдаёт retained-значения в подтверждении подписки, до первого прохода loop. Хранилище агрегатора наполняется мгновенно — если соседи честно публикуют статусы с retain. Все последние значения в первую секунду после подписки
- В aggregator пришёл home/kitchen/temp. Что сделает разбор из урока?
- После выделения имени узла остаток строки проверяется на равенство /state. Двойная проверка бесплатна и спасает при расширении шаблона подписки на home/+/#. Отбросит: хвост после имени узла не равен /state
- Печать сводки на каждое входящее сообщение при пяти узлах и периоде 10 секунд — это…
- Пять узлов умножить на шесть сообщений в минуту — тридцать печатей. Для боя мой выбор: таймер плюс снимок по запросу, печать на каждое сообщение выключается флагом. Около 30 строк в минуту: годится для отладки, шумно в бою