Skip to content

ADR-014: Sistema OTA v3 con arquitectura por capas y rollback nativo ​

Fecha: 2026-06-28 Estado: Implementado

Contexto ​

El firmware v0.9.0 incluye una implementación OTA básica (ArduinoOTA + HTTP Update) que fue suficiente para el desarrollo local pero no apta para producción remota. Los problemas identificados:

  1. Partición única de aplicación — Usa default_16MB.csv sin slot A/B, imposibilitando rollback
  2. Sin integración con FSM — El estado ST_OTA_UPDATING no existe; no se previene que la OTA ocurra en estados degradados
  3. HTTP sin TLS — HTTPClient en plano, sin WiFiClientSecure; el binario viaja en texto claro
  4. Sin safe shutdown — Los SSR y actuadores no se apagan antes de la OTA; podrían recibir comandos durante la actualización
  5. Sin confirmación post-boot — No se verifica que el nuevo firmware funcione; no hay rollback automático
  6. Sin telemetría OTA — No hay publicación de eventos ota/status ni ota/rejected
  7. Stack insuficiente — 4096 words para la tarea OTA es insuficiente cuando se agregue TLS
  8. Código muerto — startArduinoOTA() y startHTTPUpdate() están definidos pero nunca invocados

Se requiere una arquitectura OTA que garantice seguridad, resiliencia y visibilidad en producción.

Decisión ​

Implementar el sistema OTA v3 definido en docs/roadmap.md (Fase 7) con los siguientes pilares arquitectónicos:

P1: Partición flash OTA dual con otadata ​

Usar tabla de particiones personalizada con dos slots de aplicación (app0, app1), partición otadata para metadatos del bootloader, y coredump para diagnóstico de panics.

csv
nvs,      data, nvs,      0x9000,   0x5000,
otadata,  data, ota,      0xe000,   0x2000,
app0,     app,  ota_0,    0x10000,  0x330000,
app1,     app,  ota_1,    0x340000, 0x330000,
spiffs,   data, spiffs,   0x670000, 0x180000,
coredump, data, coredump, 0x7F0000, 0x10000,

Motivo: Esquema estándar de Espressif para OTA. El bootloader gestiona el slot activo y el pending, permitiendo rollback sin código adicional.

P2: Rollback nativo del bootloader (CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE) ​

Activar el flag que el bootloader del ESP32-S3 ya soporta nativamente:

OTA exitoso → bootloader marca app nueva como ESP_OTA_IMG_PENDING_VERIFY
    → app arranca → self-test → esp_ota_mark_app_valid_cancel_rollback()
    → si falla o no confirma → bootloader hace rollback automático

Motivo: El bootloader ya implementa esta lógica. Implementar rollback custom con flags EEPROM añade complejidad y fragilidad sin beneficio.

P3: Arquitectura por capas (4 capas) ​

┌─────────────────────────────────┐
│         DECISOR OTA             │  FSM · validación · condiciones
└────────────┬────────────────────┘
┌────────────▼────────────────────┐
│       SAFE SHUTDOWN             │  SSR off · sensores en reposo
└────────────┬────────────────────┘
┌────────────▼────────────────────┐
│        EJECUTOR OTA             │  HTTPS · tarea FreeRTOS dedicada
└────────────┬────────────────────┘
┌────────────▼────────────────────┐
│    CONFIRMACIÓN POST-BOOT       │  self-test · rollback nativo
└─────────────────────────────────┘

Motivo: Separación estricta de responsabilidades. Cada capa es un archivo separado (ota_decisor, ota_shutdown, ota_executor, ota_postboot). Ninguna capa mezcla responsabilidades.

P4: HTTPS estricto con CA cert embebido ​

Usar WiFiClientSecure con CA certificate embebido como constante en el binario. Prohibido explícitamente setInsecure().

Motivo: Un servidor HTTPS sin validación de certificado equivale a HTTP plano. El CA cert pesa ~1.5 KB y cabe cómodamente en flash.

P5: MQTT como canal de comando OTA ​

El comando ota/command llega vía MQTT (no HTTP polling) para evitar latencia de polling y permitir notificaciones push desde el servidor.

Motivo: El protocolo HTTP existente es polling con latencia de hasta 5s. MQTT permite entrega inmediata y retain en estados finales.

P6: Tarea FreeRTOS dedicada en Core 0 con stack 8192 words ​

La descarga OTA corre en una tarea separada en Core 0 (red), con stack dimensionado para TLS (8192 words = ~32 KB).

Motivo: TLS consume heap significativo. Con 4096 words actuales, WiFiClientSecure causa stack overflow (observado en v0.8.1). Core 1 queda libre para control del ambiente.

Consecuencias ​

Positivas ​

  • Rollback automático sin código: el bootloader lo gestiona nativamente
  • La descarga OTA no interrumpe el control del ambiente (Core 0 vs Core 1)
  • Visibilidad completa del ciclo OTA vía MQTT con retain
  • El servidor puede detectar rollbacks por ausencia de OTA_SUCCESS post-reboot
  • Arquitectura extensible: se puede agregar validación SHA-256 en el Decisor sin tocar el Ejecutor

Negativas ​

  • Requerimiento bloqueante: Cambiar la tabla de particiones requiere borrar flash completo de todos los dispositivos en campo. No hay migración suave.
  • Espacio en disco: Cada slot de 3.25 MB reduce el espacio disponible para SPIFFS (1.5 MB vs 10+ MB actuales)
  • Dependencia de MQTT: Si el broker MQTT no está disponible, el comando OTA no puede enviarse (aunque el Decisor puede rechazar igualmente si MQTT no está conectado)
  • CA cert management: El certificado embebido caduca; requiere actualización del firmware si el cert del servidor cambia

Mitigaciones ​

  • Para el borrado de flash: documentar el procedimiento y ejecutarlo una sola vez en el primer deploy de OTA v3
  • Para el espacio SPIFFS: el roadmap asigna 1.5 MB (suficiente para logs, config y certs)
  • Para la caducidad del CA cert: CI/CD que valida expiración del cert con alerta < 30 días; script de renovación automática

Alternativas descartadas ​

AlternativaRazón por la que se descartó
Factory + OTA (una partición factory + una OTA)Factory no se puede actualizar; desperdicia 3.25 MB. Dual OTA permite que ambos slots sean actualizables.
Rollback custom con flags en NVSEl bootloader ya lo hace nativamente. Reimplementar añade riesgo de bugs y no aporta ventajas.
setInsecure() + hash SHA-256El hash verifica integridad pero no autenticidad. Un MITM puede reemplazar binario + hash. CA cert resuelve ambos.
HTTP con firmas (binario + .sig)Complejidad adicional. HTTPS con CA cert cubre confidencialidad + integridad + autenticidad simultáneamente para el contexto actual.
OTA por BLEEl ESP32-S3 lo soporta pero añade superficie de ataque. No hay ventaja sobre HTTPS para este caso de uso.
Delta OTA (parches binarios)Complejidad no justificada para el volumen de dispositivos esperado.
Secure Boot + Flash EncryptionRequiere quemar claves en eFuse, válido para producto final pero no para desarrollo activo. Se documenta como futuro.

Atributos de calidad ​

AtributoCómo se aborda
SeguridadHTTPS con CA cert, SHA-256 verification (mbedtls), validación de URL, versión
ResilienciaRollback nativo del bootloader, safe shutdown, restore post-fallo
DisponibilidadOTA en Core 0, control en Core 1 — no hay downtime del ambiente
Mantenibilidad4 capas separadas en archivos individuales, cada una con única responsabilidad
Observabilidad5 eventos MQTT con retain, silencio post-reboot = rollback
PerformanceStack 8192 words, tarea dedicada, sin bloqueo del sistema

Referencias ​


Implementación (2026-06-29) ​

Archivos creados ​

ArchivoPropósito
firmware/src/ota_nvs.{h,cpp}Inicialización NVS (namespace mush2, key fw_version), esquema v1
firmware/src/ota_decisor.{h,cpp}OTASelector: validación de URL, SemVer, RSSI mínimo
firmware/src/ota_shutdown.{h,cpp}OTAShutdown: apagado seguro de SSR, sensores, comunicaciones
firmware/src/ota_executor.{h,cpp}OTAExecutor: descarga HTTPS + SHA-256 verify (mbedtls) + Update.write()
firmware/src/ota_postboot.{h,cpp}OTAConfirmation: self-test (WiFi, I2C, AHT21, heap) + confirm()
firmware/src/mqtt_client.{h,cpp}Cliente MQTT con PubSubClient, callback OTA 3 params (url, version, hash)
firmware/src/state_machine.{h,cpp}Matriz 9×9 con fsmTransition(), transición INIT→SAFE, NVS persistence
firmware/partitions.csvOTA dual 8MB (app0/app1, spiffs, coredump)

Flujo OTA v3 completo ​

1. Comando OTA (MQTT `ota/command` o serial `ota https://...`)
2. OTASelector → valida URL, SemVer, RSSI → rechaza o autoriza
3. OTAShutdown → SSR off, sensores reposo, comunicaciones detenidas
4. FSM → ST_OTA_UPDATING
5. OTAExecutor → HTTP GET con WiFiClient → SHA-256 verify (mbedtls) → Update.write() → reboot
6. Post-boot → OTAConfirmation → self-test → confirm() vía MQTT

MQTT integrado ​

  • Subscribe: mush2/{deviceId}/ota/command (url + version)
  • Publish: ota/rejected (causa), ota/status (estados con retain)
  • Callback otaMqttCallback() almacena comando en variables globales volátiles

Fixes aplicados post-verificación (generic-report-2.md) ​

#FixArchivoJustificación
1STACK_POLLER 4096→8192config.example.hJsonDocument en runParse() desbordaba 4K
2DELAY_POLLER 100→500msconfig.example.hReducir CPU waste 50→10 iteraciones/segundo
3DELAY_MQTT 50→500msconfig.example.hEvitar reconnect loop agresivo en el broker
4client.connect(host, port, 5000)http_poller.cppTimeout explícito de 5s en connect TCP
5continue → goto ota_skipmain.ino taskOTA()Cada continue saltaba vTaskDelayUntil
6mush2_%s → %smqtt_client.cppDevice ID ya incluye prefijo mush2_
7client.flush() + vTaskDelay(50) post-connecthttp_poller.cppAsegurar que datos salgan al wire
8De-chunking de HTTP chunked encodinghttp_poller.cppManejar Transfer-Encoding: chunked del server
9BACKEND_PORT 3000→3797config.example.hCoincidir con puerto real del backend Express

SHA-256 Verification vía mbedtls (2026-07-11) ​

El ejecutor OTA ahora verifica la integridad del firmware descargado usando SHA-256 via la librería mbedtls del ESP-IDF:

cpp
// ota_executor.cpp
#include "mbedtls/sha256.h"
#include "mbedtls/error.h"

bool OTAExecutor::verifySha256(const uint8_t* data, size_t len, const char* expectedHash) {
  mbedtls_sha256_context ctx;
  mbedtls_sha256_init(&ctx);
  mbedtls_sha256_starts(&ctx, 0);
  mbedtls_sha256_update(&ctx, data, len);
  uint8_t hash[32];
  mbedtls_sha256_finish(&ctx, hash);
  mbedtls_sha256_free(&ctx);

  // Comparar con hash esperado
  char hashHex[65];
  for (int i = 0; i < 32; i++) sprintf(hashHex + i*2, "%02x", hash[i]);
  return strncasecmp(hashHex, expectedHash, 64) == 0;
}
AspectoDetalle
Libreríambedtls (incluida en ESP-IDF)
CampoOtaCandidate.hash (String, hex de 64 chars)
Fuente del hashComando MQTT: {"url":"...", "version":"...", "hash":"..."}
ComportamientoSi no hay hash → verificación omitida (backward compatible)
Si fallaOTA abortada, publica ota/rejected con causa: "hash_no_coincide"

Callback MQTT actualizado (3 parámetros):

cpp
// mqtt_client.h
typedef void (*OtaCallback)(const char* url, const char* version, const char* hash);
void setOtaCallback(OtaCallback cb);

Comprobación en hardware ​

  • Test HTTP vía example.com:80: 869 bytes recibidos correctamente ✓
  • Test server Node.js local (backend/test-server.js): 184 bytes, de-chunked 173 bytes, JSON parseado ✓
  • Backend real Express + PostgreSQL: 221 bytes, JSON con deviceId + actuators[] ✓
  • MQTT: Conexión y suscripción a ota/command verificadas ✓
  • OTA vía ArduinoOTA: Upload exitoso a 192.168.1.24:3232 con auth ✓
  • HTTP: OK confirmado en [STATS] con backend real ✓

Mush2 — Sistema IoT de control ambiental