Логгер в теплице я вытащил через две недели работы ради одной бумаги: заказчик попросил показать температуру за прошлую неделю. Снял корпус, вынул microSD, протащил её через три сотни метров к дому, воткнул в кардридер, скопировал лог, собрал обратно — двадцать минут на то, что в браузере заняло бы десять секунд. Карта в устройстве — ловушка, которую заранее не видно: данные на ней есть, а добираться до них неудобно ровно настолько, что вы начнёте пропускать чтения, а потом и вовсе забьёте.
Выход, к которому я в итоге пришёл, — поставить в устройство ESP32 вместо Uno и открыть карту по WiFi: плата держит веб-сервер на порту 80, браузер показывает список файлов, клик по ссылке скачивает лог. Карта остаётся на месте, устройство остаётся на месте, файлы едут по воздуху. Железа сверх того, что уже есть в логгере, — ноль: та же microSD и та же шина SPI, только распиновка другая, — она расписана в уроке про распиновку SPI.
Почему не Uno: у него два килобайта оперативной памяти, и даже буфер на 512 байт для него — четверть всего стека. У ESP32 в арсенале около 320 КБ, из которых скетч с WiFi и картой съедает 70-100, а радиомодуль встроен в чип — никакого сетевого шилда не нужно. Разберём механику стриминга файла кусками, три заголовка HTTP, без которых браузер ломается, список файлов ссылками и ловушку, в которую я попал сам: сервер, честно отдающий все файлы подряд, однажды отдаёт и ваш конфиг с паролем WiFi.
Маршрут файла: от кластера до окна браузера
Файл лога за месяц весит 5-15 МБ — в сотни раз больше всей памяти ESP32. Поэтому главная идея урока обидно проста: файл никогда не читается целиком, он перекачивается кусками. В цикле вызываю file.read(buf, sizeof(buf)) — библиотека кладёт в буфер порцию байт с текущей позиции, дальше client.write(buf, n) отправляет её в сеть, и так до конца: available() скажет, когда байты кончились. Это то же чтение потока, что разобрано в уроке про режимы File, только сток байт не Serial, а браузер. В памяти всё это время живёт один буфер на 1-2 КБ — RAM занята на константу, файл — хоть на двадцать мегабайт.
Тупик, в котором я провёл вечер: пытался отдать файл одним вызовом, как в примерах для ESP8266, где client.write(file) принимает открытый файл целиком. У ESP32 такого перегруженного вызова нет, а у ESP8266 он внутри делает ровно тот же цикл с буфером, просто спрятанный. Второй грабель был крупнее: буфер на 8 КБ я объявил прямо в функции — а стек задачи loop на ESP32 в несколько килобайт, и плата перезагружалась не на первом же чанке, а через несколько сотен, что маскировало причину под плавающую. С тех пор буфер у меня глобальный, 2048 байт — потолок, после которого прирост скорости не окупает риск.
Числа, на которые можно рассчитывать: при уверенном приёме WiFi файл 5 МБ уезжает в браузер за 4-8 секунд, и узкое место — не эфир, а SPI карты. Модуль microSD на ESP32 гоняют на 20-25 МГц, что с накладными расходами файловой системы даёт порядка 1-1,5 МБ/с. Быстрее не выйдет, пока карта сидит на SPI; у ESP32 есть ещё SD_MMC с родной четырёхбитной шиной — втрое-вчетверо быстрее, но это другая разводка и другой урок.
#include <WiFi.h>#include <SPI.h>#include <SD.h> const char* ssid = "DachaNet";const char* pass = "parol_seti";const int cs = 5; // CS карты на GPIO5, не на GPIO12 WiFiServer server(80);uint8_t buf[2048]; // буфер одного куска, глобальный void setup() { Serial.begin(115200); if (!SD.begin(cs)) { Serial.println("SD error"); while (true) { } } WiFi.begin(ssid, pass); while (WiFi.status() != WL_CONNECTED) { delay(300); } Serial.println(WiFi.localIP()); server.begin();} void loop() { WiFiClient client = server.available(); if (!client) return; String req = client.readStringUntil('\n'); if (req.indexOf("GET / ") >= 0) { // страница со списком File root = SD.open("/"); String page = "<html><body>"; for (File f = root.openNextFile(); f; f = root.openNextFile()) { page += "<a href='/get?f="; page += f.name(); page += "'>"; page += f.name(); page += "</a><br>"; } page += "</body></html>"; client.println("HTTP/1.1 200 OK"); client.println("Content-Type: text/html"); client.println("Cache-Control: no-store"); client.print("Content-Length: "); client.println(page.length()); client.println(); client.print(page); } else if (req.indexOf("GET /get?f=") >= 0) { int p = req.indexOf("f=") + 2; int e = req.indexOf(" HTTP"); String name = req.substring(p, e); bool ok = true; // белый список вместо доверия for (unsigned int i = 0; i < name.length(); i++) { char c = name[i]; if (!(isalnum(c) || c == '.' || c == '_')) ok = false; } if (name.startsWith("conf")) ok = false; // конфиг не отдаём File f = SD.open(name); if (!ok || !f) { client.println("HTTP/1.1 404 Not Found"); client.println("Content-Length: 0"); client.println(); } else { client.println("HTTP/1.1 200 OK"); client.println("Content-Type: application/octet-stream"); client.println("Content-Disposition: attachment; filename=" + name); client.print("Content-Length: "); client.println(f.size()); client.println(); while (f.available()) { // стриминг кусками int n = f.read(buf, sizeof(buf)); client.write(buf, n); } f.close(); } } client.stop();}Смотрите на два маршрута. GET / собирает страницу со ссылками: SD.open("/") открывает корень как папку, openNextFile() перебирает файлы, и на каждый лепится обычный HTML-тег ссылки. Никакого фреймворка: сервер — это вы, а не класс с роутерами; если раздача разрастётся, посмотрите урок про маршруты веб-сервера, где тот же сайт строится на WebServer и хендлерах. Здесь же у меня маршрутов два, и руками выходит короче, чем конфигурация.
Второй маршрут — тот самый стриминг. Заголовки перед телом файла — договор с браузером, и в таблице ниже все четыре, без которых раздача ломается. Самый коварный — Content-Length: без него браузер получает байты, но не знает, где конец, и крутит загрузчик до таймаута — минута-другая тишины, потом страница сообщает о сбое соединения. С заголовком прогресс-бар честно ползёт по процентам, а соединение закрывается сразу после последнего байта: f.size() отдаёт длину до чтения, отдельного прохода по файлу не требуется.
Cache-Control: no-store для страницы списка я добавил после того, как полчаса искал, куда делись новые файлы: браузер прятал старый список в кэш и показывал его вместо свежего — файлы на карте были, ссылок на них не было. Для скачиваемых файлов этот заголовок не критичен: имя уникально, содержимое меняется редко.
| Заголовок | Что обещает браузеру | Без него | Мой вариант |
|---|---|---|---|
| Content-Length | размер тела в байтах | загрузчик крутится до таймаута | всегда, берётся из f.size() |
| Content-Disposition: attachment | файл скачается, а не откроется | CSV откроется как текст в окне | filename=run.log, латиница без пробелов |
| Content-Type | тип содержимого | браузер угадывает и мажет | octet-stream для логов, text/html для списка |
| Cache-Control: no-store | страница всегда свежая | кэш покажет старый список | только для HTML-страницы |
Загрузка на карту, имена и симулятор
Обратная операция — залить файл на карту из браузера — делается POST-запросом с multipart/form-data: браузер шлёт тело кусками с границей-boundary, сервер ищет эту границу в потоке, вытаскивает имя файла и пишет байты на карту. Это втрое больше кода, чем скачивание, и на коленке за вечер не пишется; в моих устройствах я до сих пор закрываюсь скачиванием, а новые файлы кладу на карту через кардридер при редком обслуживании. Когда загрузка понадобится в полный рост — в том же уроке про маршруты есть разбор POST-обработчиков, отталкивайтесь от него.
Имена файлов на карте — короткие и латиницей: run.log, d2512.csv. Файловая система FAT хранит короткие имена в формате восемь-точка-три, а длинные и кириллические едут в расширенных записях каталога — подробности в уроке про формат FAT16 и FAT32. Для ссылок это ещё и вопрос парсинга: пробел в имени превращается в %20 и ломает наивный разбор строки запроса, поэтому пробелы я в именах не допускаю вообще.
В модели коэффициент тот же, что на реальной плате: каждое обращение к карте — накладные расходы файловой системы, поэтому куски крупнее — оборотов меньше. На 128 байт файл в 2048 проходит за 16 шагов, на 512 — за четыре; на настоящем ESP32 я держу 2048 байт, а 1024 — если RAM жалко и буфер просится в стек. Крутаните потенциометр в обе стороны и посчитайте шаги в Serial — это ровно то решение, которое вы примете, объявляя размер buf в скетче.
База, без которой этот урок не собирался: сам веб-сервер с разбором клиента и ответа разобран в уроке про первый веб-сервер на ESP32 — если там ещё не были, начните с него, здесь механика запросов не повторяется. И не забудьте физику: CS карты на ESP32 традиционно сажают на GPIO5 — GPIO12 для этого запрещён, там стрэп-пин, о котором расскажу в уроке про логгер на батарейке. Скачивание — половина дела; вторая половина — чтобы файл появлялся на карте без вашего визита, и этим займёмся там же.
- 1. Почему файл отдают циклом file.read и client.write с буфером, а не целиком?
- 2. Что увидит браузер, если в ответе нет Content-Length?
- 3. Какой заголовок заставляет браузер скачать файл, а не открыть его в окне?
- 4. Чем опасен сервер, отдающий любой файл по имени из запроса?
- 5. Какой размер куска разумен для стриминга на ESP32?
- Файл перекачивается циклом file.read(buf, n) — client.write(buf, n): в RAM живёт один кусок 1-2 КБ, файл может быть в сотни раз больше памяти
- Content-Length из f.size() обязателен: без него браузер ждёт до таймаута; attachment принуждает скачивание, no-store держит список свежим
- Список файлов — перебор корня openNextFile() и обычные HTML-ссылки; POST-загрузка — multipart с boundary и отдельный большой код
- Белый список расширений, посимвольный фильтр и запрет conf: сервер, отдающий всё подряд, рано или поздно отдаст конфиг с паролем
- CS карты на ESP32 — GPIO5; на GPIO12 стрэп-пин, с картой на нём плата не стартует
Частые вопросы по теме урока
- Почему файл отдают циклом file.read и client.write с буфером, а не целиком?
- Лог за месяц — единицы и десятки мегабайт против сотен килобайт памяти. Цикл держит в RAM один кусок, поэтому размер файла не ограничен памятью платы. Файлы обычно на порядки больше свободной RAM: кусками через буфер — единственный путь
- Что увидит браузер, если в ответе нет Content-Length?
- Без объявления длины конец тела определяется только разрывом соединения, на который сервер не решается. Браузер ждёт до таймаута и показывает сбой. Загрузчик будет крутиться до таймаута: конец ответа неизвестен
- Какой заголовок заставляет браузер скачать файл, а не открыть его в окне?
- attachment с filename= переключает браузер в режим скачивания. Content-Type лишь подсказывает тип, и один он не запрещает открыть CSV как текст. Content-Disposition: attachment
- Чем опасен сервер, отдающий любой файл по имени из запроса?
- Имя из запроса — чужой ввод. Без белого списка и фильтра символов скачивается конфиг с секретами, а пути с ../ ведут мимо ожидаемых файлов. Отдаст config.txt с паролем WiFi и пропустит пути вида ../
- Какой размер куска разумен для стриминга на ESP32?
- Буфер 1024-2048 байт — золотая середина: крупные куски режут число обращений к карте, но буфер в стеке задачи уложит плату. Мой глобальный буфер — 2048. 1-2 КБ: дальше прирост не окупает риск переполнения стека