Своими рукамипрактическая инструкция

Обновляем ESP32 по Wi-Fi: первая прошивка и пробный откат

Редакция Фермозавра·12 апреля 2026 г.·13 мин чтения

Поменяем программу ESP32 через браузер, проверим подпись файла и попробуем возврат к предыдущей версии. Начнём на запасной плате, которую удобно держать под рукой.

Обновляем ESP32 по Wi-Fi: первая прошивка и пробный откат

Новая программа без похода к USB-разъёму

Датчик уже стоит в теплице, а в программе захотелось изменить поведение или добавить функцию. С OTA обновление можно передать по сети. Сначала освоим этот путь рядом с компьютером: загрузим первую версию по USB, затем увидим новое имя выпуска после обновления через браузер.

Это лабораторный проект для обычного ESP32 с 4 МБ flash. Исполнительные нагрузки подключать пока не будем. Разберём загрузку подписанного файла и дополнительный вариант получения обновлений по HTTPS. При переносе в автоматику понадобится режим обслуживания с независимым безопасным состоянием нагрузок.

Используем Arduino-ESP32 3.3.12, ArduinoJson 7.4.3 и разметку min_spiffs. В ней два слота приложения по 1 966 080 байт: один для текущего выпуска, другой для кандидата. Проверьте размер всего подписанного файла со служебным блоком. Если плата или разметка другая, сначала уточните размеры её слотов.

Первую установку выполняют по USB с соответствующими загрузчиком и таблицей разделов. Для последующих OTA нужен бинарный файл приложения, а не объединённый образ, загрузчик или таблица разделов. При выборе схемы без двух OTA-слотов этот пример не подходит.

Делаем подпись, которую узнает наша плата

Пароль ограничивает доступ к веб-загрузчику, а цифровая подпись проверяет происхождение прошивки. Здесь используется штатный механизм Signed OTA Update с ECDSA-P256 и SHA-256. Закрытый ключ хранится на компьютере сборки; на ESP32 нужен только открытый.

Найдите каталог установленного пакета Arduino-ESP32 3.3.12. В командах замените /PATH/TO/ARDUINO_ESP32 его реальным путём. Выполняйте генерацию один раз в отдельном защищённом каталоге: последующие выпуски подписывают тем же ключом.

python3 -m venv .venv
.venv/bin/pip install cryptography
.venv/bin/python /PATH/TO/ARDUINO_ESP32/tools/bin_signing.py --generate-key ecdsa-p256 --out private_key.pem
.venv/bin/python /PATH/TO/ARDUINO_ESP32/tools/bin_signing.py --extract-pubkey private_key.pem --out public_key.pem

Утилита создаст также public_key.h. Скопируйте его в каталог скетча; закрытый ключ туда не переносите и на сервер обновлений не загружайте. Для разных проектов и несовместимых вариантов оборудования лучше разделять ключи и каталоги выпусков, чтобы не принять подписанный, но неподходящий образ.

Создайте рядом файл build_opt.h с единственной строкой ниже. Это включает проверку подписи при сборке библиотеки Update; одного определения внутри скетча недостаточно.

-DUPDATE_SIGN

Сохраните основной код как ota.ino. Укажите Wi-Fi и отдельный пароль OTA длиной не менее 16 символов; стандартное значение не разрешает запуск веб-загрузчика. APP_LABEL — видимое имя выпуска: меняйте его при новой сборке. Проверка обновлений с сервера изначально отключена.

// Arduino-ESP32 3.3.12. Отдельный учебный узел без исполнительных нагрузок.
#include <WiFi.h>
#include <WebServer.h>
#include <NetworkClientSecure.h>
#include <HTTPClient.h>
#include <Update.h>
#include <SHA2Builder.h>
#include <ArduinoJson.h>
#include <Preferences.h>
#include <esp_ota_ops.h>
#include <esp_task_wdt.h>
#include <esp_random.h>
#include <time.h>
#include "public_key.h"
#ifndef UPDATE_SIGN
#error "Place -DUPDATE_SIGN in build_opt.h"
#endif
#if !CONFIG_APP_ROLLBACK_ENABLE || !CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE
#error "This example requires rollback-enabled app and bootloader"
#endif

const char *WIFI_SSID = "CHANGE_ME";
const char *WIFI_PASSWORD = "CHANGE_ME";
const char *OTA_PASSWORD = "CHANGE_ME";
const char *APP_LABEL = "demo-1"; // Меняйте при каждой сборке.
const char *MODEL = "esp32-demo-4m-minspiffs";
const bool PULL_ENABLED = false;
const char *MANIFEST_URL = "https://example.invalid/ota/manifest.json";
const char *IMAGE_URL = "https://example.invalid/ota/firmware_signed.bin";
const uint32_t CONFIRM_MS = 120000, TRANSFER_MS = 90000;
const uint32_t POLL_MS = 3600000; // Проверка раз в час, не сразу после загрузки.

WebServer server(80);
Preferences prefs;
UpdaterECDSAVerifier verifier(PUBLIC_KEY, PUBLIC_KEY_LEN, HASH_SHA256);
SHA256Builder digest;
String csrf, uploadToken;
size_t expectedBytes = 0, receivedBytes = 0;
uint32_t transferStarted = 0, bootStarted = 0, lastPoll = 0, rebootAt = 0;
bool pendingVerify = false, writing = false, uploadComplete = false;
bool uploadRejected = false, webStarted = false, storageReady = false;
unsigned uploadFiles = 0;

// Не подтверждать новый образ в initArduino(), до setup().
extern "C" bool verifyRollbackLater() { return true; }

bool decimalSize(const String &text, size_t &value) {
  if (text.isEmpty() || text.length() > 8) return false;
  size_t result = 0;
  for (size_t i = 0; i < text.length(); ++i) {
    if (text[i] < '0' || text[i] > '9') return false;
    result = result * 10 + size_t(text[i] - '0');
  }
  value = result;
  return true;
}
bool hexDigest(const String &text) {
  if (text.length() != 64) return false;
  for (size_t i = 0; i < 64; ++i)
    if (!((text[i] >= '0' && text[i] <= '9') || (text[i] >= 'a' && text[i] <= 'f'))) return false;
  return true;
}
void rotateToken() {
  uint8_t randomBytes[16];
  esp_fill_random(randomBytes, sizeof(randomBytes));
  csrf = "";
  char pair[3];
  for (uint8_t b : randomBytes) { snprintf(pair, sizeof(pair), "%02x", b); csrf += pair; }
}
bool credentials() { return server.authenticate("admin", OTA_PASSWORD); }
bool tokenMatches(const String &token) { return token.length() == 32 && token == csrf; }
void abortImage() {
  if (writing || Update.isRunning()) Update.abort();
  writing = false;
  uploadComplete = false;
}
bool beginImage(size_t total) {
  const esp_partition_t *slot = esp_ota_get_next_update_partition(nullptr);
  if (pendingVerify || writing || !storageReady || !slot || total <= 512 || total > slot->size) return false;
  expectedBytes = total;
  receivedBytes = 0;
  transferStarted = millis();
  digest.begin();
  if (!Update.installSignature(&verifier) || !Update.begin(total, U_FLASH)) { abortImage(); return false; }
  writing = true;
  return true;
}
bool writeImage(uint8_t *data, size_t count) {
  if (!writing || uint32_t(millis() - transferStarted) >= TRANSFER_MS || count > expectedBytes - receivedBytes) {
    abortImage(); return false;
  }
  if (Update.write(data, count) != count) { abortImage(); return false; }
  digest.add(data, count);
  receivedBytes += count;
  esp_task_wdt_reset();
  return true;
}
bool commitImage(const String &requiredHash) {
  if (!writing || receivedBytes != expectedBytes || uint32_t(millis() - transferStarted) >= TRANSFER_MS) {
    abortImage(); return false;
  }
  digest.calculate();
  String hash = digest.toString();
  if (!requiredHash.isEmpty() && hash != requiredHash) { abortImage(); return false; }
  // Запомнить кандидат ДО активации. После неудачного старта/отката
  // автоматическая проверка не будет снова устанавливать тот же файл.
  if (prefs.putString("attempt", hash) != 64 || prefs.getString("attempt", "") != hash) {
    abortImage(); return false;
  }
  bool ok = Update.end(false); // Требуются все байты и правильная подпись.
  writing = false;
  if (!ok) { Update.abort(); return false; }
  rebootAt = millis() + 500;
  if (!rebootAt) rebootAt = 1;
  return true;
}
void receiveUpload() {
  HTTPUpload &file = server.upload();
  if (file.status == UPLOAD_FILE_START) {
    ++uploadFiles;
    size_t total = 0;
    bool allowed = credentials() && tokenMatches(server.header("X-OTA-Token"));
    if (uploadFiles != 1 || !allowed || !decimalSize(server.header("X-OTA-Size"), total) || !beginImage(total)) {
      uploadRejected = true; abortImage(); return;
    }
    uploadToken = csrf;
    rotateToken(); // Одноразовый токен; второй файл не разрешён.
  } else if (file.status == UPLOAD_FILE_WRITE) {
    if (uploadRejected || !writeImage(file.buf, file.currentSize)) uploadRejected = true;
  } else if (file.status == UPLOAD_FILE_END) {
    if (!uploadRejected && writing && file.totalSize == expectedBytes && receivedBytes == expectedBytes)
      uploadComplete = true; // Активация только после завершения всего POST.
    else { uploadRejected = true; abortImage(); }
  } else if (file.status == UPLOAD_FILE_ABORTED) {
    abortImage(); uploadRejected = false; uploadFiles = 0; uploadToken = "";
  }
}
void finishUpload() {
  bool authorized = credentials() && !uploadToken.isEmpty() && server.header("X-OTA-Token") == uploadToken;
  bool ok = authorized && !uploadRejected && uploadFiles == 1 && uploadComplete && commitImage("");
  if (!ok) abortImage();
  server.send(ok ? 200 : 400, "text/plain; charset=utf-8",
              ok ? "Файл проверен. Перезагрузка; затем подтвердите новую версию." : "Обновление отклонено. Перезагрузка не назначена.");
  uploadRejected = false; uploadFiles = 0; uploadComplete = false; uploadToken = "";
}
void page() {
  if (!credentials()) { server.requestAuthentication(); return; }
  server.sendHeader("Cache-Control", "no-store");
  String html = "<!doctype html><meta charset='utf-8'><title>Обновление ESP32</title><h1>Обновление ESP32</h1><p>Версия: ";
  html += APP_LABEL;
  html += pendingVerify ? "; ожидает подтверждения.</p>" : "; обычный режим.</p>";
  html += "<input id='file' type='file' accept='.bin'><button onclick='upload()'>Загрузить подписанный файл</button>";
  html += "<button onclick='confirmImage()'>Подтвердить проверенную версию</button><pre id='status'></pre><script>const token='";
  html += csrf;
  html += R"HTML(';const status=document.getElementById('status');
async function upload(){const f=document.getElementById('file').files[0];if(!f)return;
const data=new FormData();data.append('firmware',f);
try{const r=await fetch('/update',{method:'POST',headers:{'X-OTA-Token':token,'X-OTA-Size':String(f.size)},body:data});status.textContent=await r.text();}
catch(e){status.textContent='Связь прервана. Проверьте устройство и обновите страницу.';}}
async function confirmImage(){const r=await fetch('/confirm',{method:'POST',headers:{'X-OTA-Token':token}});status.textContent=await r.text();}
</script>)HTML";
  server.send(200, "text/html; charset=utf-8", html);
}
void confirmImage() {
  if (!credentials() || !tokenMatches(server.header("X-OTA-Token"))) { server.send(403, "text/plain", "Denied"); return; }
  if (!pendingVerify || writing || !storageReady || uint32_t(millis() - bootStarted) >= CONFIRM_MS) { server.send(409, "text/plain", "Not pending"); return; }
  if (esp_ota_mark_app_valid_cancel_rollback() != ESP_OK) { server.send(500, "text/plain", "Confirmation failed"); return; }
  pendingVerify = false; rotateToken();
  server.send(200, "text/plain; charset=utf-8", "Версия подтверждена оператором.");
}

bool getManifest(String &hash, size_t &bytes) {
  NetworkClientSecure tls;
  tls.useBuiltinCACertBundle(); tls.setHandshakeTimeout(5); tls.setTimeout(5000);
  HTTPClient http;
  http.setConnectTimeout(3000); http.setTimeout(5000);
  http.setFollowRedirects(HTTPC_DISABLE_FOLLOW_REDIRECTS);
  if (!http.begin(tls, MANIFEST_URL)) return false;
  int status = http.GET(), count = http.getSize();
  if (status != 200 || count <= 0 || count > 2048) { http.end(); return false; }
  char text[2049];
  size_t got = http.getStreamPtr()->readBytes(text, count);
  http.end();
  if (got != size_t(count)) return false;
  JsonDocument doc;
  if (deserializeJson(doc, text, got) || !doc["model"].is<const char *>() ||
      String(doc["model"].as<const char *>()) != MODEL || !doc["sha256"].is<const char *>() ||
      !doc["bytes"].is<unsigned>()) return false;
  hash = doc["sha256"].as<const char *>(); bytes = doc["bytes"].as<unsigned>();
  return hexDigest(hash) && bytes > 512;
}
void checkForUpdate() {
  if (pendingVerify || writing || !storageReady || WiFi.status() != WL_CONNECTED || time(nullptr) < 1700000000) return;
  String hash; size_t total = 0;
  if (!getManifest(hash, total) || hash == prefs.getString("attempt", "")) return;
  esp_task_wdt_reset();
  NetworkClientSecure tls;
  tls.useBuiltinCACertBundle(); tls.setHandshakeTimeout(5); tls.setTimeout(5000);
  HTTPClient http;
  http.setConnectTimeout(3000); http.setTimeout(5000);
  http.setFollowRedirects(HTTPC_DISABLE_FOLLOW_REDIRECTS);
  if (!http.begin(tls, IMAGE_URL)) return;
  int status = http.GET();
  if (status != 200 || http.getSize() <= 0 || size_t(http.getSize()) != total || !beginImage(total)) { http.end(); return; }
  auto *stream = http.getStreamPtr();
  uint8_t buffer[1024];
  uint32_t lastByte = millis();
  while (writing && receivedBytes < total && uint32_t(millis() - transferStarted) < TRANSFER_MS) {
    int available = stream->available();
    if (available > 0) {
      size_t n = min(size_t(available), min(sizeof(buffer), total - receivedBytes));
      int got = stream->read(buffer, n);
      if (got <= 0 || !writeImage(buffer, size_t(got))) break;
      lastByte = millis();
    } else if (!http.connected() || uint32_t(millis() - lastByte) >= 5000) break;
    delay(1);
  }
  http.end();
  if (!commitImage(hash)) abortImage();
}
void setup() {
  Serial.begin(115200);
  bootStarted = lastPoll = millis();
  esp_ota_img_states_t state;
  pendingVerify = esp_ota_get_state_partition(esp_ota_get_running_partition(), &state) == ESP_OK && state == ESP_OTA_IMG_PENDING_VERIFY;
  storageReady = prefs.begin("ota-demo", false);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  while (WiFi.status() != WL_CONNECTED && uint32_t(millis() - bootStarted) < 20000) delay(100);
  configTime(0, 0, "pool.ntp.org", "time.cloudflare.com");
  esp_task_wdt_config_t config = {20000, 0, true};
  esp_err_t rc = esp_task_wdt_init(&config);
  if (rc == ESP_ERR_INVALID_STATE) rc = esp_task_wdt_reconfigure(&config);
  if (rc != ESP_OK || (esp_task_wdt_status(nullptr) != ESP_OK && esp_task_wdt_add(nullptr) != ESP_OK)) {
    Serial.println("Watchdog unavailable; OTA disabled"); return;
  }
  if (WiFi.status() != WL_CONNECTED || strlen(OTA_PASSWORD) < 16 || String(OTA_PASSWORD) == "CHANGE_ME") return;
  rotateToken();
  const char *headers[] = {"X-OTA-Token", "X-OTA-Size"}; server.collectHeaders(headers, 2);
  server.on("/", HTTP_GET, page);
  server.on("/update", HTTP_POST, finishUpload, receiveUpload);
  server.on("/confirm", HTTP_POST, confirmImage);
  server.begin(); webStarted = true;
  Serial.println(WiFi.localIP());
}
void loop() {
  esp_task_wdt_reset();
  if (rebootAt && int32_t(millis() - rebootAt) >= 0) ESP.restart();
  if (pendingVerify && uint32_t(millis() - bootStarted) >= CONFIRM_MS) {
    abortImage();
    esp_ota_mark_app_invalid_rollback_and_reboot();
    Serial.println("Rollback unavailable; OTA stopped");
    while (true) delay(1000); // Watchdog перезапустит; нужен доступ для восстановления.
  }
  if (writing && uint32_t(millis() - transferStarted) >= TRANSFER_MS) {
    abortImage(); uploadRejected = false; uploadFiles = 0; uploadToken = ""; rotateToken();
  }
  if (webStarted && !rebootAt) server.handleClient();
  if (PULL_ENABLED && webStarted && !rebootAt && !pendingVerify && uint32_t(millis() - lastPoll) >= POLL_MS) {
    lastPoll = millis(); checkForUpdate();
  }
  delay(2);
}

Меняем имя выпуска и загружаем его через браузер

Соберите и загрузите первую версию по USB. После подключения ESP32 к Wi-Fi адрес появится в Serial Monitor; откройте http://АДРЕС_ESP32/ и войдите как admin с заданным паролем. Используйте отдельную доверенную сеть обслуживания: HTTP Basic Auth не шифрует пароль и трафик. Подпись прошивки не делает HTTP конфиденциальным.

Для нового выпуска измените APP_LABEL и нужную логику, сохранив механизм обновления и подтверждения. Экспортируйте бинарный файл приложения, затем подпишите и проверьте его:

.venv/bin/python /PATH/TO/ARDUINO_ESP32/tools/bin_signing.py --bin /PATH/TO/ota.ino.bin --key private_key.pem --out firmware_signed.bin
.venv/bin/python /PATH/TO/ARDUINO_ESP32/tools/bin_signing.py --verify firmware_signed.bin --pubkey public_key.pem

Выберите firmware_signed.bin в веб-форме. Обработчик проверяет авторизацию и одноразовый токен до записи, принимает один файл с объявленным размером и активирует его только после завершения всего запроса. Неполная передача, неверная подпись или ошибка записи не назначают штатную перезагрузку; при зависании соединения сторожевой таймер может перезапустить узел.

После загрузки обновите страницу и получите новый токен. Дождитесь перезапуска, найдите новое имя выпуска и проверьте работу приложения. Так вы пройдёте весь путь от файла на компьютере до запущенной программы.

Пробуем вернуться к предыдущей версии

Теперь самый полезный опыт: оставить новый выпуск неподтверждённым. Откат зависит от загрузчика, конфигурации и подтверждения приложения. В используемой сборке он включён, но обычный Arduino подтверждает образ ещё до setup(). Поэтому в нашем скетче подтверждение перенесено на этап проверки оператором.

Функция verifyRollbackLater() откладывает подтверждение. После обновления проверьте имя выпуска, связь и функции, затем нажмите «Подтвердить проверенную версию». Окно составляет 120 секунд от начала setup(), вместе с ожиданием Wi-Fi. В другом опыте оставьте версию неподтверждённой: по истечении окна программа запросит откат. Следующее обновление в этом состоянии блокируется.

Для возврата нужен пригодный предыдущий образ. Сам факт наличия двух слотов не гарантирует восстановление, а зависание до инициализации сторожевого таймера не покрывается этим примером. После подтверждения поздняя ошибка приложения также не вызывает автоматический откат. Сохраняйте USB-доступ и заранее проверьте неудачный запуск на запасной плате.

Когда будете переносить механизм в датчик, включите в проверку измерения, настройки, доставку данных и исполнительные блокировки. Если меняется формат NVS, сохраните совместимость с предыдущей программой: при возврате старого приложения содержимое памяти остаётся отдельной задачей.

Плата сама забирает файл с сервера

Следующий вариант удобен, когда ESP32 выходит в сеть, а подключиться к нему снаружи сложно. Подготовьте HTTPS-сервер с корректным сертификатом, подписанным файлом и небольшим JSON-манифестом. Файлы должны отдаваться с известной длиной и без перенаправлений.

Сохраните следующий скрипт как make_manifest.py. Он вычисляет размер и SHA-256 именно подписанного файла; для проверки цифровой подписи используется отдельная команда выше.

import hashlib, json, sys
from pathlib import Path
file = Path(sys.argv[1])
data = file.read_bytes()
if len(data) <= 512:
    raise SystemExit('Expected signed application binary')
print(json.dumps({'model':'esp32-demo-4m-minspiffs','bytes':len(data),
                  'sha256':hashlib.sha256(data).hexdigest()},indent=2))
python3 make_manifest.py firmware_signed.bin > manifest.json

Разместите файл и манифест в отдельном каталоге для этой модели. Сначала заменяйте бинарный файл, затем манифест; несовпадение хеша во время обновления каталога приведёт к отказу, а не к активации. Доступ на запись к серверу должен быть ограничен, закрытый ключ остаётся вне него.

Укажите реальные MANIFEST_URL и IMAGE_URL, после испытаний включите PULL_ENABLED. Проверка выполняется раз в час, первая — через час после запуска. Код проверяет время для TLS, сертификат сервера, модель, длину, хеш и подпись; размеры ожиданий в коде — параметры примера, которые проверяют на своей сети.

Попробуйте откат и в этом режиме. Перед активацией хеш кандидата сохраняется в NVS, поэтому после возврата тот же файл автоматически снова не установится. Если связь оборвалась до проверки полного файла, попытка повторится при следующем опросе. Уже занесённый в журнал кандидат можно загрузить вручную через форму или заменить исправленным выпуском с другим хешем.

После сетевого обновления здесь также требуется подтверждение в заданное окно. Планируйте его на время обслуживания; для парка устройств нужна отдельная процедура проверки здоровья и подтверждения. Этот журнал не является защитой от установки любой старой подписанной версии: аппаратный anti-rollback в примере не настраивается.

Для доступа к веб-странице удалённого узла настройте VPN на маршрутизаторе или шлюзе объекта и проверьте маршрутизацию. Не открывайте HTTP-порт ESP32 в интернет. VPN защищает свой участок соединения; локальный участок от шлюза до ESP32 в этом примере остаётся HTTP и требует доверенной сети.

Готовим обновление для своего устройства

Перед переносом в работающий контроллер определите режим обслуживания: состояние нагрузок, аппаратные блокировки, питание и запуск после перезагрузки. Сеть и запись flash могут задерживать основной цикл. Подстройте сторожевой таймер и подтверждение под функции своего устройства.

Это особенно важно для проектов автополива на ESP32 и вентиляции теплицы. Первые испытания проводят на отдельной плате без подключённых исполнительных нагрузок.

  • Прямая отправка файла без пароля и с неверным токеном не начинает запись.
  • Повреждённая подпись, обрыв, слишком большой файл и ошибка памяти не приводят к успешной активации.
  • Новая версия доступна после перезагрузки; без подтверждения выполняется возврат, а после подтверждения сохраняется выбранный выпуск.
  • Неудачный кандидат с сервера не загружается снова по кругу; USB-восстановление и исходный рабочий образ доступны.

Закончите знакомство двумя опытами на запасной плате: подтвердите исправный выпуск, затем проверьте возврат с неподтверждённого. Держите USB-доступ под рукой и запишите порядок восстановления. После этого путь обновления будет понятен ещё до установки устройства в теплице.

💬 Комментарии

Чтобы оставить комментарий, войдите или зарегистрируйтесь

Загрузка комментариев...

Похожие статьи