У дачного табло погоды был один источник — открытый API с городом, — и всё было хорошо, пока жена не попросила добавить на тот же экран состояние теплицы: температура с собственного сервера в сарае. Я вписал второй GET-запрос в loop() рядом с первым и получил сразу две беды. Табло начало обновляться раз в минуту вместо десяти секунд, а мигающий курсор-разделитель замирал на полсекунды, будто плата спотыкается на каждом шагу. Вторая беда вылезла через сутки: кнопки перестали отзываться вовсе, а опросы в Serial шли всё реже и реже — соединения где-то копились, и плата медленно захлёбывалась.
Обе беды выросли из одной привычки: писать сетевой код так, будто запрос мгновенный. Он не мгновенный: http.GET() блокирует loop() на всё время, пока сервер соображает, а по умолчанию в ядре arduino-esp32 клиент ждёт ответа до 5000 мс. Два запроса подряд в одном проходе цикла — до десяти секунд полной глухоты: ни кнопок, ни экрана, ни мигалок. А объект HTTPClient, оставленный без http.end(), держит после себя соединение и буферы — десятки таких забытых клиентов съедают память, и плата ведёт себя как герой детектива: ещё работает, но уже странно.
В этом уроке разведём два опроса по тайм-слотам на millis(): свой сервер — каждые 30 секунд, городской API — раз в минуту. Правило одного запроса за проход loop(), отдельные объекты и end() в той же функции, где begin, — и скетч держит оба источника, не забывая ни про кнопку, ни про экран.
Тайм-слоты: два будильника в одном цикле
Идея та же, что в millis-мигалке без delay, только будильников два и у каждого своё расписание. На каждый опрос — своя отметка unsigned long: lastA для сервера теплицы, lastB для городского API. В каждом проходе loop() смотрим, истёк ли интервал: millis() - lastA >= 30000 — пора к теплице, millis() - lastB >= 60000 — пора в город. Сравнение через вычитание не прихоть: беззнаковая разность остаётся верной, когда millis() переполнится через 49,7 суток, а лобовое millis() > lastA + 30000 на том же переполнении сломается.
Разнесённые интервалы почти никогда не совпадают, но раз в минуту оба таймера истекают в одном проходе — 60 секунд кратно и тридцати, и шестидесяти. Отсюда мой закон: один проход loop() — один сетевой запрос. Два подряд — это два блокирующих GET друг за другом, до десяти секунд ступора, и все мигалки это почувствуют. Второй опрос просто переезжает на следующий тик: опоздание на миллисекунды никто не заметит, а цикл остаётся живым. Хочется жёстких гарантий между источниками — есть путь суровее: разнести их по задачам FreeRTOS на два ядра, как я показывал в уроке про двойное ядро и уроке про очереди и семафоры. Но в девяти проектах из десяти хватает тайм-слотов, которые видно глазами целиком.
Сколько времени на самом деле занимает один запрос? На домашнем сервере в сарае — 100-300 мс, на внешнем API — от полсекунды до полутора, и такие окна экран и кнопки даже не замечают. Драма начинается, когда сервер молчит: окно растягивается до таймаута, пять секунд на запрос, и вот тут правило одного запроса за проход превращается из стилистики в необходимость. В симуляторе GET отвечает за полсекунды всегда — виртуальные серверы не молчат; на живой плате проверьте свой худший случай: выключите роутер и посмотрите в Serial, сколько реально длится отказ.
#include <WiFi.h>#include <HTTPClient.h> const char* ssid = "DomaSetka";const char* password = "superParol"; unsigned long lastA = 0; // сервер теплицы, каждые 30 сunsigned long lastB = 0; // городской API, каждые 60 с void setup() { Serial.begin(115200); WiFi.begin(ssid, password); while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); } Serial.println();} // один опрос = один HTTPClient = один end(): мусора не остаётсяint oprosit(String url, String metka) { HTTPClient http; http.begin(url); http.setConnectTimeout(3000); // максимум 3 с на соединение int kod = http.GET(); if (kod == 200) { Serial.print(metka); Serial.print(" kod=200 telo="); Serial.println(http.getString()); } else { Serial.print(metka); Serial.print(" kod="); Serial.println(kod); // -1 — до сервера не добрались } http.end(); // прибрались всегда, при любом коде return kod;} void loop() { unsigned long now = millis(); if (now - lastA >= 30000) { // теплица: раз в 30 с lastA = now; oprosit("http://192.168.1.50/teplica", "[TEPLICA]"); } else if (now - lastB >= 60000) { // город: раз в 60 с lastB = now; oprosit("http://api.example.com/v1/weather", "[GOROD]"); } // здесь живут мигалки, кнопки и экран — их никто не держит}Каждый вызов oprosit() живёт по правилу «пришёл — спросил — прибрался»: локальный объект HTTPClient создался, сделал begin/GET/getString и закрылся вызовом http.end(). end() — это client.stop() плюс выгрузка заголовков из памяти; без него каждый запрос оставляет после себя соединение и буферы. Я ставлю end() в той же функции, где begin(): при таком порядке прибираться приходится всегда, что бы ни вернул GET, — и утечка невозможна даже в тех ветках, где тело не читается. Если хочется завести один общий клиент и переиспользовать его — легально, но тогда end() перед каждым begin() обязателен, и следите, чтобы ни одна ветка не выпрыгивала из функции между ними.
Функция oprosit() возвращает код не для красоты: выше по скетчу его можно сосчитать в счётчик отказов, зажечь светодиод или записать строку в журнал — именно так мы поступим в двух следующих уроках. Возвращённое значение — дешёвая страховка на будущее: скетч, где результат запроса уже выведен наружу, расширять на порядок проще, чем тот, где он тонет внутри функции.
Обратите внимание на else if в планировщике: в один проход цикла срабатывает максимум одна ветка. Даже если оба интервала истекли разом — после долгого обрыва, например, — теплица спросится сейчас, город в следующем проходе, через миллисекунду. Честность ради: два независимых if тоже работают и компилируются, но на живой плате я однажды поймал ровно то, от чего страхует else: городской API задумался, тепличный запрос ждал за ним, и экран на десять секунд замер при формально «правильном» скетче. После этого случая правило «один тик — один запрос» висит у меня над столом.
Интервалы 30 и 60 секунд я выбрал не с потолка. Тепличный датчик живёт своей жизнью, его опоздание на полминуты ничего не меняет. А городскому API я читал условия тарифа: бесплатные планы просят не дёргать их чаще раза в минуту, и у одного сервиса в списке лимитов прямо стояла строчка «60 запросов в час». Своим серверам в сарае я задаю хоть секунду; чужим — ставлю интервал с запасом, потому что смена правил на чужой стороне не приходит с уведомлением. Что делать с телом ответа, когда оно пришло, — отдельная история: разбор JSON по HTTP раскладывает его по переменным, здесь же я оставил печать сырой строки.
| Подход | Что блокирует | Когда беру |
|---|---|---|
| delay(30000) между опросами | Всё: кнопки и экран мертвы между запросами | Нигде; только черновая проверка на столе |
| Тайм-слоты на millis() | Один GET в свой слот — доли секунды | Два-три источника: стандартный выбор |
| FreeRTOS-задачи на два ядра | loop() не блокируется вовсе | Много источников и жёсткие тайминги |
| Один общий таймер на всё | Ничего лишнего, но период один на все источники | Когда интервалы действительно равны |
Проба в симуляторе
Движок эмулирует оба этажа, которые нужны этому скетчу: WiFi.begin подключает виртуальную плату за секунду-другую, а HTTPClient честно выполняет begin/GET/getString/end — код 200 и тело приходят настоящими. Одна условность: оба «сервера» отвечают одним демо-телом с Москвой и температурой — демо-сеть у движка одна на все адреса, различить источники по телу не выйдет; на живой плате ответы, само собой, разные. Зато расписание видно идеально: строки [TEPLICA] идут каждые 30 секунд, [GOROD] — раз в минуту, и системная строка HTTP: GET перед каждым запросом показывает, кто именно спросил. Включите скорость ×10 — опросы пойдут каждые три и шесть секунд, и за минуту вы увидите полный цикл расписания, включая момент, когда оба слота совпали. Обратите внимание на порядок строк: системное HTTP: GET — это begin, затем мой вывод с кодом, затем тело и end().
- Каждому источнику — свой интервал и своя отметка millis(): разность unsigned верна и после переполнения
- Один проход loop() — один сетевой запрос: два GET подряд могут держать цикл до десяти секунд
- HTTPClient — локальный объект на запрос, begin и end() в одной функции: соединения не копятся
- Интервалы опроса чужих API сверяйте с лимитами тарифа, своим серверам задавайте по вкусу
- Тест живости цикла — кнопка: если нажатия теряются между опросами, планировщик написан на delay
- 1. Опрос A раз в 30 с, опрос B раз в 60 с. Как часто оба интервала истекают в одном проходе loop()?
- 2. Сколько времени loop() может простоять на одном http.GET() без настроенных таймаутов?
- 3. Что делает http.end() и почему он обязан стоять в каждой ветке опроса?
- 4. Почему сравнение millis() - lastA >= 30000 надёжнее лобового millis() > lastA + 30000?
- 5. Что изменится, если убрать else и оставить два независимых if в планировщике?
Тайм-слоты отвечают на вопрос «когда спрашивать», но не на вопрос «а жив ли тот, кого я спрашиваю». Оба моих источника однажды отказали в одну неделю: сервер в сарае — после скачка напряжения, городской API — сам по себе, и разбираться, что именно упало, пришлось по косвенным признакам. После этого у табло появился третий, самый частый опрос — проверка роутера раз в десять секунд со светодиодом и журналом отказов: аргумент, с которым в спорах про «у меня всё работает» больше не нужны слова. Собираем этот монитор в следующем уроке; а отправка собственных показаний наружу, если ваш проект растёт в обратную сторону, разобрана в уроке про IoT-датчик. Правило при этом переносится дальше двух источников: третьему и четвёртому достанутся свои отметки и свои интервалы, пока скетч остаётся плоским и читаемым.
Частые вопросы по теме урока
- Опрос A раз в 30 с, опрос B раз в 60 с. Как часто оба интервала истекают в одном проходе loop()?
- Общий множитель 30 и 60 — 60 секунд: раз в минуту слоты совпадают, и правило «один запрос за проход» переносит второй опрос на следующий тик. Раз в минуту
- Сколько времени loop() может простоять на одном http.GET() без настроенных таймаутов?
- В ядре arduino-esp32 HTTPClient ждёт ответа до 5000 мс по умолчанию. Два запроса подряд в одном проходе — до десяти секунд глухого цикла. До 5000 мс — дефолтный таймаут ядра
- Что делает http.end() и почему он обязан стоять в каждой ветке опроса?
- end() — это client.stop() плюс выгрузка буферов. Пары begin/end внутри одной функции — простейшая страховка от утечек соединений. Закрывает TCP-соединение и выгружает заголовки: без него клиент течёт
- Почему сравнение millis() - lastA >= 30000 надёжнее лобового millis() > lastA + 30000?
- millis() переполняется за 49,7 суток. Беззнаковое вычитание даёт верную разность и после переполнения, а сумма в правой части лобового сравнения — нет. Разность unsigned корректна при переполнении millis()
- Что изменится, если убрать else и оставить два независимых if в планировщике?
- Два if честно сработают оба, когда истекли оба интервала: два GET подряд, до десяти секунд ступора. else — дешёвая страховка. Оба опроса могут уйти в одном проходе — двойная блокировка