ESP32 y Arduino

Actualización OTA del ESP32 por MQTT: lanza actualizaciones de firmware por HTTPS e informa del estado

Lanza actualizaciones de firmware del ESP32 con un comando MQTT, descarga el binario por HTTPS con HTTPUpdate y publica el progreso y el resultado en un topic de estado retenido para saber siempre qué versión ejecuta cada dispositivo.

Dispositivos ESP32 recibiendo un comando de firmware en un flujo de actualización OTA ESP32 por MQTT con un servidor de descarga HTTPS
Pruébalo en tu propio broker MQTTConsigue un broker MQTT privado y gestionado con el plan Free y empieza con ejemplos de código listos para usar.
Pruébalo gratis Ejemplos de código Sin tarjeta

Una actualización OTA del ESP32 por MQTT te permite actualizar dispositivos en campo sin abrir la caja: publicas un comando, el dispositivo descarga el nuevo firmware por HTTPS e informa de su progreso en un topic de estado. Este tutorial construye ese flujo paso a paso con la librería HTTPUpdate de Arduino-ESP32 y muestra las salvaguardas que necesitas antes de usarlo en hardware real.

Por qué combinar MQTT y HTTPS para la OTA

Es tentador enviar el propio binario del firmware por MQTT, pero rara vez es buena idea. Las imágenes de firmware ocupan de cientos de kilobytes a unos pocos megabytes, muchos brokers y librerías cliente limitan el tamaño de los mensajes muy por debajo de eso, y trocear un binario implica escribir tu propia lógica de reensamblado, orden y checksum.

La división más limpia es dejar que cada protocolo haga aquello en lo que es bueno:

  • MQTT transporta los pequeños mensajes de control: "actualiza a la versión 1.4.0 desde esta URL", además de los informes de progreso y resultado. Los dispositivos ya están conectados, así que el comando llega al instante incluso detrás de un NAT.
  • HTTPS transporta el binario grande desde un servidor web o un almacenamiento de objetos, con TLS, gestión de rangos y caché ya resueltos.

El dispositivo nunca necesita un puerto de entrada abierto, y conservas en el broker un registro en vivo de cada intento de actualización.

El diseño de topics

Topic Dirección Payload Retenido
connectmq/demo/e47b10/<device>/ota/cmd Publica el operador {"version":"1.4.0","url":"https://..."} No
connectmq/demo/e47b10/<device>/ota/status Publica el ESP32 {"state":"downloading","progress":42} Sí
connectmq/demo/e47b10/<device>/info Publica el ESP32 {"version":"1.3.2"} Sí

Retener los topics de estado e info significa que cualquier herramienta que se suscriba más tarde, como un dashboard o un script que comprueba un despliegue, ve de inmediato la versión actual y el último resultado de OTA de cada dispositivo.

Requisitos previos

  • Un ESP32 con al menos 4 MB de flash y un esquema de particiones con dos ranuras de aplicación OTA. El esquema por defecto de Arduino las tiene; "Huge APP (no OTA)" no.
  • El core Arduino-ESP32 2.x o 3.x, que incluye HTTPUpdate.
  • Las librerías PubSubClient y ArduinoJson.
  • Un servidor HTTPS que sirva tu archivo .bin. Cualquier alojamiento de archivos estáticos sirve.

Exporta el binario con Sketch > Export Compiled Binary en el Arduino IDE, o toma .pio/build/<env>/firmware.bin de PlatformIO.

Sketch completo de actualización OTA ESP32 por MQTT

#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPUpdate.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>

#define FW_VERSION "1.3.2"

const char* WIFI_SSID = "YOUR_WIFI";
const char* WIFI_PASS = "YOUR_WIFI_PASSWORD";
const char* ALLOWED_HOST = "https://firmware.example.com/";   // aceptar solo URL bajo este prefijo

// CA raíz de tu servidor de firmware en formato PEM
const char* FIRMWARE_CA = R"EOF(
-----BEGIN CERTIFICATE-----
...paste the root CA of your download server here...
-----END CERTIFICATE-----
)EOF";

WiFiClient mqttNet;
PubSubClient mqtt(mqttNet);
String deviceId, topicCmd, topicStatus, topicInfo;

volatile bool otaRequested = false;
String otaUrl, otaVersion;
int lastReported = -1;

void publishStatus(const char* state, int progress = -1, const char* error = nullptr) {
  JsonDocument doc;
  doc["state"] = state;
  doc["from"] = FW_VERSION;
  if (otaVersion.length()) doc["to"] = otaVersion;
  if (progress >= 0) doc["progress"] = progress;
  if (error) doc["error"] = error;
  char buf[192];
  size_t n = serializeJson(doc, buf, sizeof(buf));
  mqtt.publish(topicStatus.c_str(), (const uint8_t*)buf, n, true);
}

void onMessage(char* topic, byte* payload, unsigned int length) {
  JsonDocument doc;
  if (deserializeJson(doc, payload, length)) return;
  String url = doc["url"] | "";
  String version = doc["version"] | "";
  if (!url.startsWith(ALLOWED_HOST) || version.length() == 0) {
    publishStatus("rejected", -1, "url or version invalid");
    return;
  }
  if (version == FW_VERSION) {
    publishStatus("up_to_date");
    return;
  }
  otaUrl = url;
  otaVersion = version;
  otaRequested = true;              // hacer el trabajo en loop(), no aquí
}

void onProgress(int current, int total) {
  int pct = total > 0 ? (current * 100) / total : 0;
  if (pct / 10 != lastReported / 10) {   // informar cada 10 %
    lastReported = pct;
    publishStatus("downloading", pct);
    mqtt.loop();
  }
}

void runOta() {
  otaRequested = false;
  lastReported = -1;
  publishStatus("starting");

  WiFiClientSecure https;
  https.setCACert(FIRMWARE_CA);

  httpUpdate.rebootOnUpdate(false);
  httpUpdate.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
  httpUpdate.onProgress(onProgress);

  t_httpUpdate_return result = httpUpdate.update(https, otaUrl);

  switch (result) {
    case HTTP_UPDATE_OK:
      publishStatus("success", 100);
      mqtt.loop();
      delay(500);
      ESP.restart();
      break;
    case HTTP_UPDATE_NO_UPDATES:
      publishStatus("no_update");
      break;
    case HTTP_UPDATE_FAILED:
    default:
      publishStatus("failed", -1, httpUpdate.getLastErrorString().c_str());
      break;
  }
}

void connectMqtt() {
  while (!mqtt.connected()) {
    if (mqtt.connect(deviceId.c_str(), topicStatus.c_str(), 1, true,
                     "{\"state\":\"offline\"}")) {
      mqtt.subscribe(topicCmd.c_str());
      String info = String("{\"version\":\"") + FW_VERSION + "\"}";
      mqtt.publish(topicInfo.c_str(), info.c_str(), true);
    } else {
      delay(3000);
    }
  }
}

void setup() {
  Serial.begin(115200);
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  while (WiFi.status() != WL_CONNECTED) delay(250);

  deviceId = "esp32-ota-" + String((uint32_t)ESP.getEfuseMac(), HEX);
  String base = "connectmq/demo/e47b10/" + deviceId;
  topicCmd = base + "/ota/cmd";
  topicStatus = base + "/ota/status";
  topicInfo = base + "/info";

  mqtt.setServer("broker.connectmq.com", 1883);
  mqtt.setBufferSize(512);
  mqtt.setCallback(onMessage);
  Serial.printf("Device %s running %s\n", deviceId.c_str(), FW_VERSION);
}

void loop() {
  if (!mqtt.connected()) connectMqtt();
  mqtt.loop();
  if (otaRequested) runOta();
}

Tras el primer flasheo por USB, lee el ID del dispositivo en el monitor serie y lanza una actualización:

mosquitto_sub -h broker.connectmq.com -p 1883 -i pc-e47b10 -t 'connectmq/demo/e47b10/#' -v

mosquitto_pub -h broker.connectmq.com -p 1883 -i pc-e47b10-pub \
  -t connectmq/demo/e47b10/esp32-ota-1a2b3c4d/ota/cmd \
  -m '{"version":"1.4.0","url":"https://firmware.example.com/sensor-1.4.0.bin"}'

Verás starting, una serie de mensajes downloading, success y después un mensaje info con la nueva versión cuando el dispositivo se reinicie y se reconecte.

Cómo encajan las piezas

El callback solo registra la petición. httpUpdate.update() bloquea durante toda la descarga. Ejecutarlo dentro del callback MQTT bloquearía mqtt.loop() desde dentro de sí mismo, así que el sketch activa un flag y deja que loop() inicie la actualización. Esto también evita que el dispositivo dispare el task watchdog; reinicios del task watchdog del ESP32 en proyectos MQTT explica por qué.

El progreso mantiene viva la conexión MQTT. Durante una descarga larga, el callback de progreso publica cada 10 % y llama a mqtt.loop(), que además envía los pings de keepalive para que el broker no corte la sesión a mitad de camino.

rebootOnUpdate(false) te da la última palabra. Por defecto, HTTPUpdate reinicia en cuanto se escribe la imagen. Desactivarlo permite que el dispositivo publique primero success y después se reinicie por sí mismo.

El Last Will informa de los fallos. La llamada de conexión registra un mensaje de última voluntad offline retenido en el topic de estado, de modo que un dispositivo que muere a mitad de una actualización aparece como offline en lugar de quedarse atascado en "downloading". La guía de Last Will MQTT en ESP32 cubre ese patrón en detalle.

Salvaguardas de seguridad que no debes omitir

Las actualizaciones remotas son lo más potente que puede hacer tu dispositivo, lo que convierte el comando de actualización en el objetivo más atractivo. Antes de que esto se acerque a un despliegue real:

  1. Nunca lances una OTA desde un topic público en producción. Cualquiera puede leer y escribir en el área pública del servidor MQTT público gratuito de ConnectMQ, lo cual está bien para la demo con una URL de ejemplo, pero no para dispositivos reales. Usa un broker privado con credenciales.
  2. Limita el host de descarga a una lista permitida. El sketch rechaza cualquier URL que no empiece por tu propio prefijo HTTPS, así que un comando malicioso no puede apuntar el dispositivo a un binario arbitrario.
  3. Valida el certificado TLS. Carga la CA raíz de tu servidor de firmware con setCACert(). setInsecure() permitiría a cualquiera en la ruta de red sustituir la imagen.
  4. Firma tus imágenes. Para garantías más fuertes, ESP-IDF soporta secure boot y verificación de aplicaciones firmadas, de modo que el bootloader rechaza el firmware que no hayas firmado. Esto requiere una compilación con ESP-IDF o una configuración de Arduino personalizada.
  5. Comprueba las versiones. Ignora los comandos para la versión ya instalada y plantéate rechazar los downgrades salvo que se indiquen explícitamente.

Despliegue en una flota

Paso Qué hacer
1 Sube el nuevo binario con un nombre de archivo versionado; nunca sobrescribas el anterior
2 Actualiza un dispositivo de prueba y vigila su topic de estado hasta que info muestre la nueva versión
3 Actualiza un grupo pequeño y vigila los dispositivos que se queden en offline
4 Despliega al resto por lotes, no todos a la vez
5 Mantén disponible el binario anterior para poder volver a enviarlo si hace falta

Enviar los comandos dispositivo por dispositivo, en lugar de a un topic compartido al que se suscriben todos, te da este control. Un pequeño script en Python o Node.js que lee una lista de IDs de dispositivo, publica el comando y espera cada success suele ser todo lo que necesitas como herramienta.

Solución de problemas

  • HTTP_UPDATE_FAILED con "Not Enough Space": el binario es mayor que la ranura OTA. Elige un esquema de particiones con particiones de aplicación más grandes.
  • "Wrong HTTP Code" con una redirección: algunos servidores redirigen a una CDN. El sketch sigue redirecciones estrictas; asegúrate de que la CA que cargaste también corresponde al host final.
  • Falla el handshake TLS: CA incorrecta o heap insuficiente. Libera los búferes grandes antes de llamar a runOta().
  • El dispositivo arranca con el firmware antiguo: la nueva imagen no consiguió arrancar. Prueba primero el binario por USB.
  • El broker desconecta al dispositivo durante la descarga: el callback de progreso no se ejecuta con la frecuencia suficiente para llamar a mqtt.loop(). Reduce el paso de los informes.

Ejecuta tu flujo OTA en ConnectMQ

Prueba todo el flujo en el servidor MQTT público gratuito de broker.connectmq.com (solo para pruebas y prototipos, nunca con dispositivos de producción) y después protege los topics de comandos con credenciales de ConnectMQ. ConnectMQ es un broker MQTT gestionado con aislamiento multi-tenant, client IDs vinculados a las credenciales emitidas y TLS en el 8883, de modo que solo tus herramientas pueden enviar comandos de actualización a tus dispositivos. Crea un broker en el plan gratuito Serverless en app.connectmq.com, sin tarjeta de crédito, y usa el explorador de topics en vivo para seguir el topic de estado de cada dispositivo durante un despliegue. Para el proyecto de dispositivo completo en torno a esto, consulta el tutorial de IoT con ESP32.

Preguntas frecuentes

¿Puedo enviar el propio binario del firmware por MQTT?

Es posible, pero rara vez compensa. Los binarios son grandes, el tamaño de los mensajes está limitado y necesitarías tu propio troceado y verificación. Envía una URL por MQTT y descarga el binario por HTTPS.

¿Qué pasa si se va la luz durante una actualización OTA?

El ESP32 escribe la nueva imagen en la ranura OTA inactiva y solo cambia la partición de arranque tras una escritura completa y verificada. Un corte de alimentación a mitad de la descarga deja el firmware antiguo en su sitio.

¿Por qué mi dispositivo muestra "downloading" para siempre?

Probablemente se colgó o perdió la alimentación sin una desconexión limpia. Con un Last Will retenido en el topic de estado, el broker sustituye ese estado por "offline" tras el timeout del keepalive.

¿Necesito QoS 1 para el comando OTA?

QoS 1 es una buena elección para los comandos porque garantiza la entrega mientras el dispositivo está conectado. Haz que el manejador sea idempotente ignorando los comandos para la versión que ya está instalada.

Artículos relacionados

Ejemplo de ESP-IDF MQTT: crea un cliente esp-mqtt desde cero

Construye un proyecto ESP-IDF desde una carpeta vacía con el componente esp-mqtt: configuración de Wi-Fi en modo estación, el manejador de eventos MQTT, publicación y suscripción, propiedades de MQTT 5 y TLS con el bundle de certificados.

9 min de lectura

Todos los artículos →

Tu broker MQTT privado, a unos pocos clics

Crea una cuenta, obtén las credenciales de tu broker y conecta tu primer dispositivo, sin servidores que instalar, parchear ni escalar.