Умная лампа на балконе жила по расписанию и не знала горя, пока я не решил дать ей пульт. Схема собралась за вечер: ESP32, приёмник на GPIO 15, реле, локальный сервер умного дома на старом ноуте в кладовке. Кнопка пульта должна была превращаться в HTTP-запрос к серверу — классический ИК-шлюз, про который написаны десятки статей. Первые сутки всё работало, и я уже собирался хвастаться.
А потом сервер ушёл на перезагрузку обновления, и пульт умер. Не наполовину — именно умер: жмёшь OK, через пять секунд лампа щёлкает; жмёшь быстрее — пропуски, двойные срабатывания, каша. Кадры с пульта летели исправно, приёмник мигал светодиодом, а шлюз глотал команды через раз. Диагноз прятался в одной строке: http.GET() стоял внутри decode-блока, до resume(). Пока запрос ждал мёртвый сервер, приёмник был глух, и всё, что пульт слал в это окно, пропадало без следа.
Разберём шлюз по шагам: как кнопка превращается в запрос, куда ставить resume(), зачем резать таймауты и почему repeat-кадры для HTTP — чистый вред. Тройку begin-decode-resume мы собирали ещё в уроке про приём кодов, а поля decodedIRData разобраны в уроке про анатомию кадра — здесь они превращаются из читалки в пульт управления домом.
Конвейер шлюза: четыре шага одной кнопки
Конвейер короткий: принять кадр, распознать команду, отправить запрос, ответить в Serial. Первые два шага делает библиотека: кадр NEC прилетает за 50-85 мс, decode() кладёт команду в decodedIRData.command, и у вас в руках байт. Вся инженерная работа сидит в зазорах между шагами. Буфер у приёмника один, и структура результата тоже одна: следующий кадр не разберётся и не сохранится, пока вы не вызвали resume(). Повторы при удержании идут каждые 108-110 мс — примерно сто миллисекунд на всё, что вы решили делать между decode и resume.
Ещё одна деталь, о которую я споткнулся в тот первый вечер: decode() возвращает true один раз на кадр, а зазор между повторами — это не только ваши запросы, но и вся печать в Serial. Поэтому шлюз печатает короткие пары имя=значение, а полные маршруты и отладку выносит за пределы decode-блока. Сто миллисекунд — много для байта и мало для строки; распределяйте бюджет с этим в уме.
Отсюда первое правило шлюза: resume() — до запроса, а не после. Прочитали команду, освободили буфер и только потом полезли в сеть. На живом локальном сервере GET отвечает за 20-80 мс — меньше одного кадра-повтора, и даже забытый resume() при работающем сервере почти незаметен. А вот на упавшем HTTPClient по умолчанию ждёт соединение до пяти секунд и ещё пять на ответ: в это окно помещается около сорока повторных кадров, и все они пропадают. setConnectTimeout(2000) срезает худший случай, а повторные кадры шлюз просто отбрасывает — серверу не нужно девять одинаковых запросов в секунду.
ESP32 здесь не гость: IRremote четвёртой версии собирается под неё без правок, приёмник садится на любой свободный GPIO, и оговорки про пины Uno не работают. Как ставить библиотеку и чем четвёртая версия отличается от старых скетчей — отдельный урок про установку и API, там же таблица миграции для кода 2017 года.
#include <IRremote.hpp>#include <WiFi.h>#include <HTTPClient.h> const char* WIFI_NAME = "dom-wifi";const char* WIFI_PASS = "parol-seti";const char* SERVER = "http://192.168.1.50:8123";const int IR_PIN = 15; // на ESP32 приёмник живёт на любом свободном GPIO void otslatZapros(String put) { HTTPClient http; http.setConnectTimeout(2000); // мёртвый сервер не должен держать loop пять секунд http.begin(String(SERVER) + put); int code = http.GET(); http.end(); Serial.print("code="); Serial.println(code);} void setup() { Serial.begin(115200); IrReceiver.begin(IR_PIN, ENABLE_LED_FEEDBACK); WiFi.begin(WIFI_NAME, WIFI_PASS); while (WiFi.status() != WL_CONNECTED) { delay(300); Serial.print("."); } Serial.println(WiFi.localIP());} void loop() { if (IrReceiver.decode()) { byte cmd = IrReceiver.decodedIRData.command; bool povtor = IrReceiver.decodedIRData.flags & IRDATA_FLAGS_IS_REPEAT; IrReceiver.resume(); // буфер свободен ДО долгого запроса if (povtor) { return; // удержание шлёт около 9 кадров в секунду - хватит одного } if (cmd == 67) { otslatZapros("/light/on"); } else if (cmd == 70) { otslatZapros("/light/off"); } else if (cmd == 9) { otslatZapros("/fan/toggle"); } }}Смотрите на порядок строк: decode, чтение команды и флага, resume, и только потом сеть. Флаг IRDATA_FLAGS_IS_REPEAT из урока про команды и повторы работает фильтром спама: без него удержание кнопки превращается в девять запросов в секунду, и сервер умного дома начнёт тормозить ровно в тот вечер, когда вам понадобится скорость. Фильтр стоит одной строкой, а экономит и серверу, и приёмнику.
GET выбран не случайно: у запроса нет тела, шлюз ничего не решает сам, и повторный GET безопасен — свет включится, а не перевернётся в противоположное состояние. Toggle-логику оставьте серверу: шлюз говорит «включи», а не «переключи». Когда понадобится передавать параметр — яркость, температуру, цвет — вот там нужен POST с телом; базовые вызовы уже разобраны в уроке про HTTP на ESP32. Ту же идею карты «кнопка — команда» продают и готовые продукты: в WLED кнопки любого совместимого пульта назначаются на команды JSON-API лампы, причём без единой строчки собственного кода.
Ответ сервера я всегда показываю жестом: код 200 — светодиод на плате вспыхнул на полсекунды. Балкон, полутьма, сервер в кладовке — без такого подтверждения нажатие повисает в воздухе, рука жмёт кнопку второй раз и порождает те самые двойные срабатывания из вступления.
Команды я сразу заворачиваю в именованные константы над setup(): CMD_LIGHT_ON = 67, CMD_LIGHT_OFF = 70, CMD_FAN = 9. Через месяц код с голыми числами превращается в загадку, а сравнение с CMD_LIGHT_ON читается и через год — компилятор подставит те же байты, а замена бесплатная. Маршруты при этом остаются строками: команда и путь живут рядом, и добавить кнопку — значит дописать две строки, а не разгадывать собственный шифр.
| Кнопка пульта | command | Маршрут GET | Что делает сервер |
|---|---|---|---|
| OK / PLAY | 67 | /light/on | замыкает реле света |
| CH- | 70 | /light/off | размыкает реле света |
| EQ | 9 | /fan/toggle | переключает режим вентилятора |
| остальные | любой | маршрут не найден | шлюз печатает route=none и сервер не трогает |
Пара слов про Serial, который в шлюзе не украшение, а журнал. На ESP32 монитор открывают на 115200 бод: строка в 30 символов стоит около 2,6 мс, и печать перестаёт быть паузой в loop. На Uno с его 9600 та же строка тянется 30 мс — для шлюза это заметная вставка, в которой повторные кадры съедаются. Правило простое: где быстрый протокол — там быстрый порт, а печать внутри decode-блока и вовсе держите на минимуме.
Второе, что стоит решить заранее: что делать с ответом, отличным от 200. Я не повторяю запрос автоматически — рука сама нажмёт кнопку ещё раз, когда увидит, что лампа не среагировала; автоповтор на упавшем сервере лишь удлиняет очередь. Код печатается в Serial всегда: журнал из пар code= и route= за вечер покажет и сбои сервера, и кнопки, для которых маршрута ещё нет. Когда маршрутов станет больше пяти, перейдите от каскада if к switch — та же история читаемости, что и в любом скетче с пультом.
И одна проверка перед боем: первая кнопка шлюза должна ломать ничего. Заведите маршрут на мигание светодиода и гоняйте его сутки, пока не убедитесь, что кадры не теряются и коды стабильны; реле света подключайте вторым шагом, когда журнал чистый.
Симулятор: шлюз без Wi-Fi
- Конвейер шлюза: принять кадр, распознать команду, отправить запрос, ответить в Serial
- resume() вызывается до запроса: буфер приёмника один, а GET может занять секунды
- HTTPClient по умолчанию ждёт до 5 секунд соединение и до 5 секунд ответ; setConnectTimeout режет худший случай
- Repeat-кадры для HTTP отбрасываем: удержание шлёт около 9 кадров в секунду — это 9 одинаковых запросов
- GET без тела безопасен при повторе; toggle-логику оставляем серверу
- Код 200 подтверждаем жестом — вспышкой светодиода, иначе рука жмёт кнопку второй раз
- 1. decode() вернул true, скетч сразу вызвал http.GET() и только потом resume(). Чем это грозит?
- 2. Сервер умного дома упал. Сколько продержится loop() на одном http.GET() без настроенных таймаутов?
- 3. Зачем шлюзу флаг IRDATA_FLAGS_IS_REPEAT?
- 4. Что возвращает http.GET()?
- 5. Почему для включения света выбран GET без тела, а не POST?
Такой шлюз даёт пульту вторую жизнь: кнопки, к которым рука привыкла за годы, управляют домом напрямую. Дальше по теме две дороги. Первая — обратное направление: сервер шлёт шлюзу команду, а тот передаёт её ИК-диодом технике, которая иначе не слушается; на форумах ардуинщиков пару приёмник-передатчик на NodeMCU собирают чаще всего ради кондиционеров. Вторая — наращивать чувствительность логики: сетевые вызовы, вписанные в loop без блокировок, выглядят так же, как таймеры из урока про millis вместо delay, — планировщик там общий. А когда в доме окажется несколько пультов и вы захотите развести их по устройствам — это тема третьего урока тройки.
Частые вопросы по теме урока
- decode() вернул true, скетч сразу вызвал http.GET() и только потом resume(). Чем это грозит?
- Между decode() и resume() приёмник глух. Пока запрос ждёт сеть — секунды, — повторные кадры, идущие каждые 108-110 мс, теряются все до единого. Порядок в скетче один: сначала resume, потом сеть. Окно глухоты растягивается до секунд — кадры, пришедшие во время запроса, пропадают
- Сервер умного дома упал. Сколько продержится loop() на одном http.GET() без настроенных таймаутов?
- Дефолтные таймауты HTTPClient — по пять секунд на соединение и чтение ответа. setConnectTimeout(2000) сокращает ожидание соединения до двух секунд. До 5 секунд на соединение и ещё до 5 на ответ
- Зачем шлюзу флаг IRDATA_FLAGS_IS_REPEAT?
- Удержание шлёт кадр-повтор каждые 108-110 мс. Для HTTP это спам: серверу хватает первого кадра, повторные шлюз отбрасывает одной проверкой флага. Чтобы не слать 9 одинаковых запросов в секунду при удержании кнопки
- Что возвращает http.GET()?
- GET отдаёт код ответа; тело читается отдельным вызовом getString(). Для шлюза код 200 — сигнал, что сервер команду получил и исполнил. Число — код ответа сервера, например 200
- Почему для включения света выбран GET без тела, а не POST?
- Toggle-логика живёт на стороне сервера: шлюз просит «включи». Дубликат такого запроса безвреден, в отличие от команды «переключи», у которой дубликат меняет состояние. Повторный GET безопасен: свет включится, а не перевернётся в противоположное состояние