Приложение для света у меня умело ровно две вещи: on и off. А вечерами просилось «гирлянду на двадцать минут» или «яркость на треть» — и каждый раз это заканчивалось походом за ноутбуком, потому что аргумент в словарь из двух слов не помещался. Команда с параметром — это не роскошь, а обычная просьба, которую панель не выражала.
Выход нашёлся в стиле телеграм-переписки: payload — это сообщение, где до первого пробела стоит глагол, а после — аргумент. set 120, timer 20, light on. Человек печатает такую строку с телефона в консоли брокера за пару секунд, скетч разбирает её двумя substring, а плата отвечает эхом — и сразу видно, что дошло и как разобралось.
Здесь собираю грамматику такого командного языка: разбор до пробела, ловушки toInt на стыке строки и числа, границы аргумента и ответ-эхо. Слои валидации — длина, словарь, диапазон — уже разобраны в уроке про валидацию payload; этот язык строится под них, а не вместо.
Грамматика: глагол, аргумент, заявка
Разбор занимает четыре строки. int probel = cmd.indexOf(" ") находит границу; verb = cmd.substring(0, probel) — глагол; arg = cmd.substring(probel + 1) — всё остальное, с trim() на всякий случай. Двойной пробел внутри отдаст пустой аргумент — и это правильный ответ: исполнителю всё равно, откуда пришёл мусор, важно, что он не прошёл. Второй аргумент, если он нужен, — второй indexOf по остатку: timer 20 60 как «двадцать минут, потом на шестьдесят процентов» — договорённость по позиции, и её надо написать комментариями рядом со словарём, потому что через полгода вы её не вспомните.
Глаголы живут в словаре — так же, как команды пульта из урока про switch-case в Serial: light, set, timer, status. Первая половина callback — разбор и запись заявки в переменные, исполнение и эхо — в loop после client.loop(): publish из callback затащил за собой петлю разрывов, на которой я обжёгся раньше; дисциплина callback-только-флаги собирается так же, как в уроке про команды через callback, добавляется лишь пара полей под глагол и аргумент.
Почему текст, а не JSON: JSON — язык для машин, панели и скрипты; разбирать его мы учились в уроке про JSON-payload. Текст с пробелом — язык для человека с клавиатурой: mosquitto_pub -t home/office/cmd -m "set 120" печатается из телефона без конвертера и читается в журнале как есть. Экономии байтов ради я текст не выбирал — там плюс-минус десяток символов; выбираю за читаемость в журнале и за ноль кавычек на клавиатуре телефона.
#include <WiFi.h>#include <PubSubClient.h> const char* WIFI_SSID = "HomeNet";const char* WIFI_PASS = "supernet123";const char* MQTT_SERVER = "192.168.1.50"; WiFiClient net;PubSubClient client(net); const char* CMD_TOPIC = "home/office/cmd";const char* ECHO_TOPIC = "home/office/echo"; const int PIN_LIGHT = 25; // реле светаconst int PIN_GARL = 26; // гирлянда, ШИМ int lightOn = 0;int garl = 0;unsigned long timerEnd = 0;int timerOn = 0; // заявка из callback: номер глагола и сырой аргументint pVerb = 0; // 1 light, 2 set, 3 timer, 4 неизвестныйString pArg = "";bool hasRequest = false; bool isDigits(String s) { if (s.length() == 0) { return false; } for (unsigned int i = 0; i < s.length(); i++) { char c = s.charAt(i); if (c < 48 || c > 57) { return false; } // цифры: коды 48..57 } return true;} int clampTo(int v, int lo, int hi) { if (v < lo) { return lo; } if (v > hi) { return hi; } return v;} void echo(String text) { client.publish(ECHO_TOPIC, text.c_str());} void callback(char* topic, byte* payload, unsigned int length) { char buf[64]; unsigned int n = length; if (n > 63) { n = 63; } // длинный payload честно режется буфером memcpy(buf, payload, n); buf[n] = 0; String cmd = String(buf); cmd.trim(); int probel = cmd.indexOf(" "); String verb = cmd; String arg = ""; if (probel >= 0) { verb = cmd.substring(0, probel); arg = cmd.substring(probel + 1); arg.trim(); } if (verb == "light") { pVerb = 1; pArg = arg; hasRequest = true; } else if (verb == "set") { pVerb = 2; pArg = arg; hasRequest = true; } else if (verb == "timer") { pVerb = 3; pArg = arg; hasRequest = true; } else { pVerb = 4; pArg = verb; hasRequest = true; }} void setup() { Serial.begin(115200); pinMode(PIN_LIGHT, OUTPUT); pinMode(PIN_GARL, OUTPUT); WiFi.begin(WIFI_SSID, WIFI_PASS); while (WiFi.status() != WL_CONNECTED) { delay(300); } client.setServer(MQTT_SERVER, 1883); client.connect("esp32-office-cmd"); client.subscribe(CMD_TOPIC); Serial.println("slushayu home/office/cmd: light on|off, set 40..255, timer 1..120");} void loop() { if (!client.connected()) { if (client.connect("esp32-office-cmd")) { client.subscribe(CMD_TOPIC); } delay(2000); return; } client.loop(); if (hasRequest) { hasRequest = false; int verb = pVerb; String arg = pArg; if (verb == 1) { if (arg == "on") { lightOn = 1; digitalWrite(PIN_LIGHT, HIGH); echo("ok light=on"); } else if (arg == "off") { lightOn = 0; digitalWrite(PIN_LIGHT, LOW); echo("ok light=off"); } else { echo("err light: zhdu on/off"); } } else if (verb == 2) { if (isDigits(arg)) { garl = clampTo(arg.toInt(), 40, 255); analogWrite(PIN_GARL, garl); echo("ok set=" + String(garl)); } else { echo("err set: ne chislo"); } } else if (verb == 3) { if (isDigits(arg)) { int minutes = clampTo(arg.toInt(), 1, 120); timerOn = 1; timerEnd = millis() + (unsigned long)minutes * 60000UL; echo("ok timer=" + String(minutes)); } else { echo("err timer: zhdu minuty 1..120"); } } else { echo("ne ponyal. glagoly: light set timer"); } } if (timerOn == 1 && millis() >= timerEnd) { timerOn = 0; garl = 0; analogWrite(PIN_GARL, 0); echo("ok timer: vremya vyshlo, garland gasla"); }}Аргумент после substring — строка, и превращение её в число полно сюрпризов. String.toInt() парсит с начала и до первого нецифрового символа: из 25x вытянет 25, из x25 вернёт 0, из пустой строки — тоже 0. Частичный разбор коварнее полного провала: таймер 20x станет двадцатью минутами, а хвост никто не заметит. Лечится проверкой isDigits до toInt: строка непуста и каждый символ — цифра. Отдельно ноль: для set он всё равно отсечётся границей 40, а вот для глагола, где 0 валиден, отличать честный ноль от мусора придётся именно isDigits — механику parseInt в потоке байтов я разбирал в уроке про parseInt.
Границы принадлежат глаголу, не аргументу: set живёт в 40..255 — ниже гирлянда глазу не видна, timer — в 1..120 минут, чтобы команда от голодного клавиатурщика не погасила свет через год. Зажим или отказ — два честных пути; я зажимаю и печатаю в эхо применённое значение: человек видит факт, а не заявку. Отрицательные числа toInt тоже съест — isDigits их отвергнет; решите заранее, бывает ли у вашего глагола отрицательный аргумент: у сдвига фазы — да, у минут — нет.
И про буфер: payload в callback не нуль-терминирован, копировать его надо с явной длиной и запасом на нулевой байт. Команда с аргументом растёт быстро: set 255 — семь байтов, но добавьте третий аргумент и длинные слова — и 64 байта кончатся; что происходит при переполнении буфера PubSubClient, считали в уроке про буфер 256: хвост молча пропадает, и разрезанная посередине команда — худший вход для валидатора.
Эхо: разговор, а не состояние
Эхо-топик — ответ на команду в человеческом виде: ok set=120, err timer: zhdu minuty 1..120. Человек, пославший set 120 с телефона, видит ответ в подписке mosquitto_sub тем же вечером — диалог закрыт, спорить не о чем. Эхо печатает факт разбора: дошло ли, что распозналось, что применилось.
Отдельный топик здесь не прихоть. state-топик отвечает за железо: свет горит, таймер идёт — и публикуется с retain по факту изменения, как в уроке про цикл команда-действие-статус. Эхо отвечает за разбор — и retain ему не нужен: это разговор, разговоры не хранят, иначе после перезагрузки панель покажет err set месячной давности как текущее состояние. Мои первые ответы уезжали прямо в state — и дашборд пару недель показывал сбой разбора вместо уровня света; второй топик дешевле часа недоумения.
Спорное место скажу честно: пока глаголов два и оба без аргументов, эхо избыточно — хватит state со словарём значений. Первый аргумент — повод завести echo. Дальше оно само окупается: на неизвестный глагол эхо отвечает списком того, что понимает, — и шпаргалка глаголов живёт не в README, а в самом протоколе.
Практическая деталь: эхо стоит печатать и на успех, и на отказ, одинаковой строкой структуры ok/err. Я пробовал отвечать только на сбой, и тишина после команды читалась как подвох: дошло ли, применилось? Ответ всегда — даже когда всё хорошо, — экономит и вторую публикацию в state: человек узнаёт факт из эха и не тревожит state без изменения.
| Способ | Кто пишет | Разбор | Где ломается |
|---|---|---|---|
| set 120 — глагол и аргумент | человек с консоли и панели | indexOf, два substring, isDigits | третий аргумент — уже договорённость по позиции |
| {"cmd":"set","v":120} | панель и скрипты | ArduinoJson: containsKey, is<T>, границы | без проверки полей null тихо станет нулём |
| home/office/level = 120 | панель | значение без глагола | топик на каждый параметр, второй параметр — новый топик |
| Мой выбор | текст — для рук, JSON — для машин | оба с границами и эхом | смешение форматов в одном топике |
Кто пишет в командный топик, тот и правит светом — права на home/office/cmd режутся в ACL так же, как на любую ветку: у человека запись есть, у соседних плат нет. Словарь глаголов при этом остаётся кодом платы: что не в словаре, то с эхом и объяснением возвращается отправителю, и железо не дёргается.
Один из глаголов заслуживает отдельной истории: timer 20 гасит гирлянду через двадцать минут, но дом умеет ещё и сами режимы — в полночь всё приглушается по расписанию, которое живёт у брокера, а не на платах. Как опубликовать режим один раз на весь дом и дать кнопке право на временный обход — тема третьего урока.
- Наберите set 120 и set 60: эхо ответит дважды применённым значением, гирлянда сменит накал — команда и ответ видны одной парой строк.
- Наберите set 25x и set: первый случай отсечёт isDigits, второй — пустой аргумент; найдите в коде обе ветки и почему toInt тут вообще не вызывается.
- Наберите timer 5 и сразу timer 30: вторая заявка перезапишет первую — таймер уедет на тридцать; найдите строку, где это происходит, и решите, честно ли это для вашего случая.
- Наберите hello: эхо вернёт шпаргалку глаголов. Добавьте свой глагол beep с аргументом 1..5 по образцу timer — словарь, границы, эхо.
- Payload — сообщение: глагол до первого пробела, аргумент после; два substring и словарь глаголов
- isDigits перед toInt: toInt парсит 25 из 25x и возвращает 0 на пустой строке — честный ноль от мусора отличает только посимвольная проверка
- Аргумент без границ — не команда: set 40..255, timer 1..120, зажим до записи в переменную
- Эхо-топик отвечает за разбор (ok/err) и живёт без retain; state-топик — за железо, с retain и по факту изменения
- Текст с глаголом — для человека с консоли, JSON — для панелей; первый аргумент — повод завести echo
- 1. Что вернёт String("25x").toInt()?
- 2. Где в скетче урока происходит разбор payload с аргументом?
- 3. Что печатает эхо-топик?
- 4. Почему у эха нет retain?
- 5. Команда set 999 при границах 40..255 что сделает с гирляндой?
Частые вопросы по теме урока
- Что вернёт String("25x").toInt()?
- toInt разбирает с начала и до первой не-цифры. Частичный разбор коварнее провала: таймер 20x честно станет двадцатью минутами, и хвост никто не заметит. Отсюда isDigits перед каждым toInt. 25: парсинг идёт до первого нецифрового символа
- Где в скетче урока происходит разбор payload с аргументом?
- Callback только разбирает и ставит флаг заявки: publish из callback — известная петля разрывов. Действие и эхо выполняются в loop при живом соединении. Callback кладёт заявку в переменные, исполнение и эхо — в loop после client.loop()
- Что печатает эхо-топик?
- Эхо — ответ на команду: дошло ли, что распозналось, что применилось. Состояние железа живёт в state-топике — не смешивайте их в одном адресе. Факт разбора: ok или err и применённое значение
- Почему у эха нет retain?
- Retain хранит последнее значение и отдаёт его каждому новому подписчику. Для состояния света это и нужно, для ответа на команду — всплывший err месячной давности. Старые ответы не должны всплывать у новых подписчиков: это разговор, а не состояние
- Команда set 999 при границах 40..255 что сделает с гирляндой?
- Границы принадлежат глаголу: значение зажимается до записи в переменную, а эхо показывает факт, а не заявку. Человек видит, что применилось именно 255. Зажмётся до 255, и эхо напечатает применённое значение