Реле на воротах гаража открылось в четыре утра. Лог показал отправителя: тестовый скрипт соседа по умной дому, который день назад стрелял в мой топик home/relay/cmd строкой « ON» — с пробелом, большой буквой и невидимым переводом строки. Сравнение payload == "on" такой payload честно отвергло, но чуть более наивный вариант с toLowerCase без trim пропустил бы. С того раза я сформулировал правило, которое больше ни разу меня не подводило: между брокером и железом стоит шлюз, и пропускает он только то, что совпало со словарём. Всё прочее — в лог, мимо реле.
Первый callback с одной командой on/off собран в уроке про команды через callback, там наивный вариант и уместен: одно устройство, ваш скрипт, никого вокруг. Здесь пишем версию для жизни: несколько отправителей, чужие руки в панели, буфер у брокера не резиновый. Разберём три слоя проверки — длина, словарь, диапазон — и отдельным заходом JSON-команды с ArduinoJson, где контракт проверяется по полям, а не по всей строке. Отправителей у командного топика на практике больше одного: панель, сценарий автоматизации, тестовый скрипт — и каждый пишет свой формат, пока вы не заставите всех говорить одним словарём.
Насчёт «кто вообще может писать в мой топик» — отдельная тема, но без неё валидация выглядит как замок на двери без стены. На своём брокере доступ режется паролями и правами в ACL, и командный топик стоит закрыть от чужих записей в первую очередь; как это выглядит в конфиге Mosquitto, показано в уроке про пароли и ACL. А если плата выезжает за роутер и слушает публичный брокер — там писать в ваш топик может любой, и валидатор из этого урока перестаёт быть опцией.
Три проверки до digitalWrite
Длина — первая и самая дешёвая. Для команд реле и света двух байт достаточно, пятнадцать — уже с запасом; всё, что длиннее, копировать в буфер и тем более исполнять бессмысленно. Второй слой — словарь: сравнение с точными допустимыми значениями после trim и toLowerCase. Не пытайтесь «починить» payload угадыванием: я делаю ровно одну уступку — пробелы по краям и регистр, и ни одной больше. Кириллическую О в команде ON не превращаю в латинскую, «вкл» не перевожу в on: панель обязана присылать словарные команды, а угадайка рано или поздно исполнит то, чего автор не хотел.
Третий слой — диапазон чисел. Команда dim 120 парсится через toInt, и тут два подвоха. Первый: toInt от строки без числа возвращает 0 — а 0 для яркости валиден, так что сравнивать результат надо с осторожностью и проверять префикс заранее. Второй: число без границ — путь к сюрпризам, dim 99999 на ШИМ-пине просто обрежется до 255, а вот «минут до автозакрытия» с таким значением повесит замок на сутки. Границы задаются в коде в одном месте: dim живёт в 40..255, минуты — в 1..120. Ловушки parseInt при потоке байтов и смешении букв с цифрами разобраны в уроке про parseInt — механика та же самая, меняется только источник байтов.
И ещё один нюанс на стыке длины и копирования: буфер под payload заводите с запасом на нулевой байт и на будущие параметры. Шестнадцати байтов для on и dim 255 достаточно, но как только у команды второй параметр, шестнадцать кончатся, и валидатор начнёт молча резать команды по границе — мусор не пройдёт, но и честная команда тоже.
#include <WiFi.h>#include <PubSubClient.h> WiFiClient net;PubSubClient client(net); const int RELAY = 7; // силовая нагрузкаconst int LED_DIM = 12; // диммируемый индикаторconst char* T_CMD = "home/relay/cmd";const char* T_STATE = "home/relay/state"; void publishState(const char* s, bool keep) { client.publish(T_STATE, s, keep); // исполнение помним (retain), отказ - разовое событие} void callback(char* topic, byte* payload, unsigned int length) { char buf[16]; // 1. длина: больше - мусор по определению unsigned int n = length < sizeof(buf) - 1 ? length : sizeof(buf) - 1; memcpy(buf, payload, n); buf[n] = 0; String cmd = String(buf); cmd.trim(); // 2. единственная уступка грязи cmd.toLowerCase(); if (cmd == "on") { digitalWrite(RELAY, HIGH); publishState("on", true); return; } if (cmd == "off") { digitalWrite(RELAY, LOW); publishState("off", true); return; } if (cmd.startsWith("dim ")) { // 3. диапазон для чисел int level = cmd.substring(4).toInt(); if (level < 40 || level > 255) { publishState("rejected-dim", false); // в лог, а не в железо return; } analogWrite(LED_DIM, level); publishState("dim", true); return; } publishState("rejected-unknown", false); // всё прочее мимо нагрузки} void setup() { Serial.begin(115200); pinMode(RELAY, OUTPUT); pinMode(LED_DIM, OUTPUT); WiFi.begin("HomeNet", "12345678"); while (WiFi.status() != WL_CONNECTED) delay(300); client.setServer("192.168.1.50", 1883); client.setCallback(callback); client.subscribe(T_CMD);} void loop() { if (!client.connected()) { if (client.connect("relay-01")) client.subscribe(T_CMD); } client.loop();}JSON-команды: контракт вместо догадок
Как только у команды прибавляется второй параметр («включи на 30 минут», «диммируй до 120 и вернись через час»), сырой payload превращается в договорённость по позиции: что значит второе слово, знает только автор скетча. JSON закрывает это: {"cmd":"on","min":30} читается панелью и платой одинаково, а заодно проверяется формально. Разбор на ArduinoJson разбирался в уроке про JSON-payload и глубже в разборе раздела ArduinoJson — здесь про слой валидации поверх.
Минимальный контракт из трёх вопросов: строка вообще разобралась (DeserializationError проверяем всегда), обязательные поля на месте (containsKey, а не обращение по ключу — отсутствие поля даёт null, который тихо превратится в ноль), и типы совпали (is<T>(): строка «30» и число 30 — разные вещи, и for-цикл по числу минут со строкой не прожувёт). Только после этого диапазоны: min в 1..120, cmd в словаре on/off/dim. Сравнение типов через is() и as() с живыми примерами лежит в уроке про проверку типов.
Ёмкость документа — часть контракта: DynamicJsonDocument(192) хватает команде с тремя полями, и память под документ я считаю от самого длинного ожидаемого payload с запасом в полтора раза, а не на глаз. Если строка не влезет, deserializeJson вернёт NoMemory и ветка rejected честно сработает — но лучше, когда это не случается на боевых командах. И держите разбор в стороне от железа: сначала doc, потом проверки, и только потом digitalWrite — тогда валидацию можно тестировать из монитора, не трогая реле. Заодно документируйте контракт рядом с кодом: один комментарий со списком полей и границ экономит переписку с автором панели и вашу собственную память через полгода.
#include <ArduinoJson.h> void callbackJson(char* topic, byte* payload, unsigned int length) { char buf[128]; unsigned int n = length < sizeof(buf) - 1 ? length : sizeof(buf) - 1; memcpy(buf, payload, n); buf[n] = 0; DynamicJsonDocument doc(192); DeserializationError err = deserializeJson(doc, buf, n); if (err) { publishState("rejected-json", false); return; } // не JSON - мимо if (!doc.containsKey("cmd") || !doc["cmd"].is<const char*>()) { publishState("rejected-shape", false); // нет поля или не строка return; } String cmd = String(doc["cmd"].as<const char*>()); if (cmd == "on" || cmd == "off") { if (doc.containsKey("min")) { // опциональное поле if (!doc["min"].is<int>()) { publishState("rejected-min-type", false); return; } int m = doc["min"]; if (m < 1 || m > 120) { publishState("rejected-min", false); return; } // здесь таймер автозакрытия на millis() на m минут } digitalWrite(RELAY, cmd == "on" ? HIGH : LOW); publishState(cmd.c_str(), true); return; } publishState("rejected-unknown", false);}| Что прилетело в cmd | Наивный скетч | Валидатор из урока |
|---|---|---|
| ON (большие буквы) | молча игнорирует: сравнение чувствительно к регистру | toLowerCase в словарь: команда исполнена, статус on |
| on с хвостовым пробелом от телефона | молча игнорирует: сравнение со "on" не сходится | trim снимает пробелы, команда исполнена |
| {"cmd":"on","min":900} | null-поле станет нулём, таймер уедет в ноль минут | containsKey + is<int> + граница 1..120: отброшено с меткой rejected |
| dim 99999 | ШИМ обрежется тихо, панель уверена, что 99999 применилось | граница 40..255: отброшено, статус rejected-dim |
Последний слой — обратная связь. Валидатор обязан отвечать: я публикую статус в соседний топик .../state с retain, и панель видит не только on, но и rejected-dim с причиной. Без ответной ветки отладка превращается в телепатию: команда ушла, ничего не произошло — почему, узнаете только вскрыв монитор. Заодно статус-топик страхует от повторов: панель, которая отправила on дважды из-за лагающей сети, видит по статусу, что исполнение одно. Механику подтверждений и цену QoS 1 для команд я раскладывал в уроке про реле с подтверждением — валидация и подтверждение работают в паре, а не вместо друг друга.
Практическая деталь про статусы: строки отбраковки не стоит публиковать с retain. Retain нужен для устойчивого состояния on и off, а отказ — событие одноразовое: если его запомнить, панель после рестарта будет уверена, что последняя команда отклонена, хотя потом всё исполнилось. Поэтому у меня два вида статусов: исполнение — с retain, отбраковка — без. Разница в одном аргументе, а поведение панели отличается принципиально.
Валидация фильтрует мусор, но не спасает от легитимной команды, отправленной дважды: панель с лагающей сетью шлёт toggle дважды, и реле честно возвращается в исходное состояние. Вывод, к которому я пришёл после сезона таинственных переключений: команды для железа делайте идемпотентными — on/off вместо toggle, абсолютная яркость вместо «прибавь», абсолютные минуты вместо «ещё на пять». Повтор тогда безопасен, а заодно словарь команд короче, и range-проверки покрывают всё. Тумблеры и инкременты оставьте интерфейсу панели — у неё есть состояние экрана и кнопка, у платы их нет, и повтор она не отличит от новой команды.
- 1. Команда dim 99999 пришла на ШИМ-канал яркости. Что сделает валидатор из урока?
- 2. Зачем в JSON-контракте containsKey перед чтением поля?
- 3. Payload длиннее буфера клиента приходит…
- 4. Почему валидатор отвечает статусом в соседний топик .../state?
- 5. Что из этого валидатор делает с грязью, а что нет?
- Три слоя проверки: длина буфера, словарь допустимых команд, диапазон чисел — и только потом железо
- Единственная уступка грязи: trim по краям и toLowerCase; угадывание синонимов запрещено
- toInt от не-числа даёт 0: проверяйте префикс команды до парсинга и держите границы чисел в одном месте
- JSON-контракт: err от deserializeJson, containsKey на обязательные поля, is<T>() на типы, диапазоны в конце
- Буфер PubSubClient по умолчанию 256 байт: длинный payload приходит усечённым; ответный статус с retain закрывает отладку
Частые вопросы по теме урока
- Команда dim 99999 пришла на ШИМ-канал яркости. Что сделает валидатор из урока?
- Числа проверяются границами до применения. Без проверки analogWrite тихо обрежет значение, панель останется уверена, что яркость 99999 применилась, — рассинхрон обеспечен. Отбросит по границе 40..255 и опубликует статус rejected-dim
- Зачем в JSON-контракте containsKey перед чтением поля?
- Обращение к отсутствующему ключу в ArduinoJson возвращает null, а тот приводится к нулю. Таймер на ноль минут или яркость на ноль — тихий промах, который containsKey ловит явно. Чтение отсутствующего поля даёт null, который тихо превратится в ноль
- Payload длиннее буфера клиента приходит…
- Библиотека режет пакет под свой буфер, по умолчанию 256 байт. Отсюда правило: длинные команды — поднять setBufferSize до connect, но лучше держать команды короткими. Усечённым: PubSubClient держит по умолчанию 256 байт
- Почему валидатор отвечает статусом в соседний топик .../state?
- Обратная связь превращает отладку в чтение: видно и исполнение, и rejected с причиной. Статус с retain заодно показывает панели последнее состояние после рестарта. Чтобы панель видела исполнение и причину отбраковки, а не гадала
- Что из этого валидатор делает с грязью, а что нет?
- Единственная уступка грязи — trim по краям и toLowerCase. Угадывание синонимов и правка символов исполняют то, чего автор не имел в виду, поэтому запрещена. Снимает пробелы по краям и регистр — и только это