Caso práctico: Acceso NFC con ESP32, PN532 y e‑paper 2.9

Caso práctico: Acceso NFC con ESP32, PN532 y e‑paper 2.9 — hero

Objetivo y caso de uso

Qué construirás: Un terminal de control de acceso NFC avanzado utilizando ESP32 y PN532, que muestra información en una pantalla E-Paper de 2.9″.

Para qué sirve

  • Control de acceso en edificios mediante identificación NFC.
  • Gestión de listas de control de acceso (ACLs) para diferentes usuarios.
  • Visualización de información de acceso en tiempo real en la pantalla E-Paper.
  • Integración con sistemas de notificación mediante MQTT para alertas de acceso.

Resultado esperado

  • Tiempo de respuesta de acceso inferior a 500 ms.
  • Capacidad para gestionar hasta 1000 usuarios en la base de datos de ACLs.
  • Latencia de comunicación entre ESP32 y PN532 menor a 50 ms.
  • Actualización de la pantalla E-Paper cada vez que se registra un acceso, con un tiempo de refresco de 1 segundo.

Público objetivo: Desarrolladores avanzados; Nivel: Avanzado

Arquitectura/flujo: ESP32 <-> PN532 <-> E-Paper <-> MQTT

Nivel: Avanzado

Prerrequisitos

Sistema operativo y utilidades

  • Windows 11 23H2, macOS 14.5 (Sonoma) o Ubuntu 22.04.5 LTS (x86_64).
  • Python 3.11.6 instalado y en PATH (recomendado usar pyenv/pipx en Linux/macOS).
  • Cable micro‑USB de datos (no solo carga).

Toolchain concreta (versiones exactas)

  • PlatformIO Core 6.1.13
  • PlatformIO Platform: espressif32 @ 6.6.0
  • Framework Arduino-ESP32: 2.0.14
  • Toolchain GCC Xtensa (ESP32): 11.2.0 (xtensa-esp32-elf-gcc 11.2.0, binutils 2.37)
  • esptool.py 4.6
  • OpenOCD (para depuración opcional): 0.12.0-esp32-20230921

Comando de verificación de versiones propuestas (tras instalar PlatformIO):
– pio –version → PlatformIO Core, versión 6.1.13
– pio pkg update; pio pkg list (dentro del proyecto) → mostrará las versiones de platform/framework/toolchain usadas.
– python –version → Python 3.11.6

Controladores USB (según tu placa)

  • NodeMCU-32S suele integrar CP210x (Silicon Labs). Instalar:
  • Windows: “CP210x Universal Windows Driver” (v11 o superior).
  • macOS: no suele requerirse desde 10.15+, pero es recomendable instalar el paquete de Silicon Labs si no aparece el puerto.
  • Linux: kernel reciente incluye cp210x. Verifica permisos de /dev/ttyUSBx o /dev/tty.SLAB_USBtoUART.
  • Algunas variantes traen CH34x:
  • Windows: CH341SER.EXE (WCH).
  • Linux/macOS: soportado en kernel modernos, pero puede requerir permisos/udev.

Notas de Linux (opcional):
– Agrega una regla udev para acceso sin sudo:
– Crear /etc/udev/rules.d/99-esp32.rules con:
– SUBSYSTEM==»tty», ATTRS{idVendor}==»10c4″, ATTRS{idProduct}==»ea60″, MODE:=»0666″
– sudo udevadm control –reload-rules && sudo udevadm trigger

Materiales

  • ESP32 NodeMCU-32S (modelo exacto de placa “NodeMCU-32S”).
  • Waveshare 2.9″ E‑Paper (controlador SSD1680) + cableado Dupont hembra-hembra.
  • Módulo PN532 NFC (Waveshare o Adafruit/Genérico) con interfaz SPI habilitada.
  • Tarjetas y/o llaveros NFC tipo A (MIFARE Classic/Ultralight/NTAG).
  • Fuente de alimentación: USB 5V (PC o cargador estable de 1A); el regulador onboard del NodeMCU-32S alimentará 3V3 para PN532 y E‑Paper.
  • Opcional: protoboard, resistencias pull-up si tu PN532 lo requiere para IRQ (no usaremos IRQ en este caso), soportes/adhesivos.

Comentarios de consumo:
– Waveshare 2.9″ e‑paper: ~26 mA durante refresco activo; ~<1 mA reposo.
– PN532: 50–80 mA durante poll activo.
– NodeMCU-32S puede alimentar ambos desde 3V3 onboard con margen si la entrada 5V es estable.

Preparación y conexión

Este caso práctico usa un bus SPI compartido (VSPI del ESP32) para E‑Paper y PN532, cada uno con su propio pin CS. El SSD1680 no requiere MISO. El PN532 sí usa MISO.

  • Bus SPI (VSPI) del ESP32 NodeMCU-32S:
  • SCLK → GPIO18
  • MOSI → GPIO23
  • MISO → GPIO19 (solo PN532)
  • Pines auxiliares E‑Paper:
  • CS, DC, RST, BUSY
  • Pines PN532:
  • SS (CS)
  • (Opcional) RSTO/IRQ: no usado en este ejemplo (polling).

Tabla de mapeo de pines y alimentación:

Módulo Señal/Pin ESP32 NodeMCU-32S Notas
Waveshare 2.9″ E‑Paper (SSD1680) VCC 3V3 Alimentación 3.3V
GND GND Tierra común
DIN (MOSI) GPIO23 Datos SPI
CLK (SCLK) GPIO18 Reloj SPI
CS GPIO5 Chip Select E‑Paper
DC (Data/Command) GPIO17 Línea DC
RST GPIO16 Reset de panel
BUSY GPIO4 Estado ocupado del panel
PN532 NFC (SPI) VCC 3V3 Alimentación 3.3V (no usar 5V en lógica)
GND GND Tierra común
SCK GPIO18 Comparte bus SPI
MOSI GPIO23 Comparte bus SPI
MISO GPIO19 Entrada MISO para PN532
SS (CS) GPIO15 Chip Select PN532
LED onboard (opcional) LED GPIO2 Indicador de acceso (on: concedido; parpadeo: denegado)

Consejos:
– Usa cables cortos y firmes. Evita falsos contactos en BUSY y DC del E‑Paper, que provocan bloqueos.
– No cruces 5V con señales lógicas. Todo va a 3.3V.
– Asegura masa común entre los módulos y el ESP32.

Código completo (Arduino/ESP32 con PlatformIO) y explicación

A continuación se muestra un proyecto completo con:
– Whitelist de UIDs basada en SHA‑256 (no se guardan UIDs en claro).
– NTP para hora real vía WiFi.
– Registro de eventos en LittleFS (CSV).
– Renderizado en E‑Paper con GxEPD2 (SSD1680).
– Lógica de control de acceso (éxito/denegado) y mitigación de ghosting (refresco completo periódico).

platformio.ini

Bloquea versiones de plataforma, framework y librerías.

; platformio.ini
[env:nodemcu-32s]
platform = espressif32 @ 6.6.0
board = nodemcu-32s
framework = arduino

; Toolchain y ajustes de carga/monitor
upload_speed = 921600
monitor_speed = 115200
monitor_filters = direct, time
board_build.flash_mode = dio

; Versiones de librerías
lib_deps =
  zinggjm/GxEPD2 @ 1.5.8
  adafruit/Adafruit PN532 @ 1.3.3
  adafruit/Adafruit BusIO @ 1.14.1
  adafruit/Adafruit GFX Library @ 1.11.9

; Opcional: reducir warnings verbosos
build_flags =
  -DCORE_DEBUG_LEVEL=0

; Asegurar framework Arduino-ESP32 versión 2.0.14
platform_packages =
  framework-arduinoespressif32@~3.20014.0
  toolchain-xtensa-esp32@~11.2.0
  tool-esptoolpy@~1.40500.0

Notas:
– framework-arduinoespressif32@3.20014.0 corresponde a Arduino-ESP32 2.0.14 en nomenclatura PlatformIO.
– toolchain-xtensa-esp32 ~11.2.0 alinea con gcc 11.2.0.

src/main.cpp

#include <Arduino.h>
#include <SPI.h>
#include <WiFi.h>
#include <esp_sntp.h>
#include <FS.h>
#include <LittleFS.h>

// E-Paper (SSD1680) con GxEPD2
#include <GxEPD2_BW.h>
#include <Fonts/FreeMonoBold12pt7b.h>
#include <Fonts/FreeMonoBold9pt7b.h>

// PN532 (SPI)
#include <Adafruit_PN532.h>

// ===== Configuración WiFi/NTP =====
static const char* WIFI_SSID     = "TU_SSID";
static const char* WIFI_PASSWORD = "TU_PASSWORD";
static const char* NTP_SERVER    = "pool.ntp.org";
static const long  GMT_OFFSET    = 0;          // ajustar según zona horaria
static const int   DST_OFFSET    = 0;          // horario de verano si aplica

// ===== Pines (ESP32 NodeMCU-32S) =====
static const int PIN_EPD_CS   = 5;
static const int PIN_EPD_DC   = 17;
static const int PIN_EPD_RST  = 16;
static const int PIN_EPD_BUSY = 4;

static const int PIN_PN532_SS = 15;

static const int PIN_LED      = 2;  // LED onboard

// ===== Objetos globales =====
GxEPD2_BW<GxEPD2_290, GxEPD2_290::HEIGHT> display(GxEPD2_290(PIN_EPD_CS, PIN_EPD_DC, PIN_EPD_RST, PIN_EPD_BUSY));
Adafruit_PN532 pn532(PIN_PN532_SS);

// ===== Whitelist (hash SHA-256 hex de UIDs) =====
// Para generar hashes, ver script Python más abajo o función utilitaria.
static const char* WHITELIST_SHA256[] = {
  // Ejemplos (reemplaza con los tuyos):
  // "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
  // "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"
};
static const size_t WHITELIST_COUNT = sizeof(WHITELIST_SHA256)/sizeof(WHITELIST_SHA256[0]);

// ===== Utilidades =====
String bytesToHex(const uint8_t* data, size_t len) {
  static const char* hex = "0123456789abcdef";
  String out; out.reserve(len * 2);
  for (size_t i = 0; i < len; ++i) {
    out += hex[(data[i] >> 4) & 0x0F];
    out += hex[data[i] & 0x0F];
  }
  return out;
}

// SHA-256 usando mbedTLS del core ESP32
#include "mbedtls/md.h"

bool sha256_hex(const uint8_t* data, size_t len, char out_hex[65]) {
  mbedtls_md_context_t ctx;
  const mbedtls_md_info_t* info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
  if (!info) return false;
  mbedtls_md_init(&ctx);
  if (mbedtls_md_setup(&ctx, info, 0) != 0) { mbedtls_md_free(&ctx); return false; }
  if (mbedtls_md_starts(&ctx) != 0) { mbedtls_md_free(&ctx); return false; }
  if (mbedtls_md_update(&ctx, data, len) != 0) { mbedtls_md_free(&ctx); return false; }
  uint8_t hash[32];
  if (mbedtls_md_finish(&ctx, hash) != 0) { mbedtls_md_free(&ctx); return false; }
  mbedtls_md_free(&ctx);
  static const char* hex = "0123456789abcdef";
  for (int i = 0; i < 32; ++i) {
    out_hex[2*i]   = hex[(hash[i] >> 4) & 0xF];
    out_hex[2*i+1] = hex[hash[i] & 0xF];
  }
  out_hex[64] = '\0';
  return true;
}

bool inWhitelist(const char* sha256_hex) {
  for (size_t i = 0; i < WHITELIST_COUNT; ++i) {
    if (strcasecmp(sha256_hex, WHITELIST_SHA256[i]) == 0) return true;
  }
  return false;
}

// Tiempo legible
String nowString() {
  struct tm timeinfo;
  if (!getLocalTime(&timeinfo, 3000)) return String("1970-01-01 00:00:00");
  char buf[32];
  strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &timeinfo);
  return String(buf);
}

// Registro en LittleFS
bool appendLog(const String& line) {
  if (!LittleFS.begin(true)) return false;
  File f = LittleFS.open("/access_log.csv", FILE_APPEND);
  if (!f) return false;
  bool ok = f.print(line);
  f.close();
  return ok;
}

// Renderizado en E‑Paper
void drawAccessScreen(bool granted, const String& uid_hex, const String& timestamp, bool partial = false) {
  if (partial) {
    display.setPartialWindow(0, 0, display.width(), display.height());
  } else {
    display.setFullWindow();
  }
  display.firstPage();
  do {
    display.fillScreen(GxEPD_WHITE);
    display.setRotation(1);
    display.setTextColor(GxEPD_BLACK);
    display.setFont(&FreeMonoBold12pt7b);

    int16_t x = 6, y = 26;
    display.setCursor(x, y);
    display.print("nfc-epaper-access-control");

    display.setFont(&FreeMonoBold9pt7b);
    y += 28;
    display.setCursor(x, y);
    display.print("UID: ");
    display.print(uid_hex);

    y += 24;
    display.setCursor(x, y);
    display.print("Time: ");
    display.print(timestamp);

    y += 30;
    display.setFont(&FreeMonoBold12pt7b);
    display.setCursor(x, y);
    if (granted) {
      display.print("ACCESS GRANTED");
    } else {
      display.print("ACCESS DENIED ");
    }

    // Pie de página
    display.setFont(&FreeMonoBold9pt7b);
    y += 28;
    display.setCursor(x, y);
    display.print("Panel: Waveshare 2.9\" (SSD1680)");
  } while (display.nextPage());
}

// Conectividad WiFi + NTP
void initWiFiNTP() {
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  Serial.print("[WiFi] Conectando a "); Serial.println(WIFI_SSID);
  unsigned long t0 = millis();
  while (WiFi.status() != WL_CONNECTED) {
    delay(300);
    Serial.print(".");
    if (millis() - t0 > 15000) break;
  }
  Serial.println();
  if (WiFi.status() == WL_CONNECTED) {
    Serial.print("[WiFi] OK. IP: "); Serial.println(WiFi.localIP());
    configTzTime("UTC0", NTP_SERVER); // se puede ajustar con TZ
    // Espera bloqueo NTP
    struct tm timeinfo;
    for (int i = 0; i < 20; ++i) {
      if (getLocalTime(&timeinfo, 500)) break;
      delay(500);
    }
    Serial.println("[NTP] Sincronizado (si red disponible).");
  } else {
    Serial.println("[WiFi] No conectado. Continuando sin NTP.");
  }
}

// Inicialización de periféricos
void initEPaper() {
  display.init(115200);  // velocidad SPI para E‑Paper
  display.setRotation(1);
  // Primera pantalla
  drawAccessScreen(false, "----", nowString(), false);
}

void initPN532() {
  pn532.begin();
  uint32_t versiondata = pn532.getFirmwareVersion();
  if (!versiondata) {
    Serial.println("[PN532] No se detecto el PN532. Verifica cableado y SS.");
  } else {
    Serial.print("[PN532] Chip PN5"); Serial.println((versiondata>>24) & 0xFF, HEX);
    Serial.print("[PN532] Firmware: "); Serial.print((versiondata>>16) & 0xFF, DEC);
    Serial.print("."); Serial.println((versiondata>>8) & 0xFF, DEC);
    pn532.SAMConfig(); // modo normal, permite lectura pasiva
  }
}

void blinkDenied() {
  for (int i = 0; i < 3; ++i) {
    digitalWrite(PIN_LED, HIGH); delay(120);
    digitalWrite(PIN_LED, LOW); delay(120);
  }
}

void solidGranted() {
  digitalWrite(PIN_LED, HIGH); delay(800);
  digitalWrite(PIN_LED, LOW);
}

void setup() {
  pinMode(PIN_LED, OUTPUT);
  digitalWrite(PIN_LED, LOW);
  Serial.begin(115200);
  delay(200);

  Serial.println("\n[nfc-epaper-access-control] Inicio");
  initWiFiNTP();
  if (!LittleFS.begin(true)) {
    Serial.println("[FS] LittleFS montado (formateado en primera vez si era necesario).");
  }

  initEPaper();
  initPN532();

  Serial.println("[Sistema] Listo. Acerca credencial NFC tipo A al PN532.");
}

void loop() {
  static uint32_t lastFullRefresh = 0;
  static int opCount = 0;

  uint8_t uid[8];
  uint8_t uidLength = 0;

  // Lee una tarjeta (timeout corto para mantener fluidez)
  bool success = pn532.readPassiveTargetID(PN532_MIFARE_ISO14443A, uid, &uidLength, 50);
  if (!success) {
    // Refresco completo cada ~60s para mitigar ghosting
    if (millis() - lastFullRefresh > 60000) {
      drawAccessScreen(false, "----", nowString(), false);
      lastFullRefresh = millis();
    }
    delay(10);
    return;
  }

  String uid_hex = bytesToHex(uid, uidLength);
  char sha_hex[65];
  if (!sha256_hex(uid, uidLength, sha_hex)) {
    Serial.println("[Crypto] Error SHA-256.");
    blinkDenied();
    return;
  }

  bool granted = inWhitelist(sha_hex);

  String ts = nowString();
  // Render parcial para respuesta rápida
  drawAccessScreen(granted, uid_hex, ts, true);
  if (granted) solidGranted(); else blinkDenied();

  // Log CSV: timestamp,uid_hex,sha256_hex,granted
  String line = ts + "," + uid_hex + "," + String(sha_hex) + "," + (granted ? "1" : "0") + "\n";
  if (!appendLog(line)) {
    Serial.println("[FS] Error escribiendo /access_log.csv");
  }

  Serial.print("[NFC] UID="); Serial.print(uid_hex);
  Serial.print(" SHA256="); Serial.print(sha_hex);
  Serial.print(" Access="); Serial.println(granted ? "GRANTED" : "DENIED");

  opCount++;
  // Forzar refresco completo cada 10 operaciones para borrar ghosting
  if (opCount % 10 == 0) {
    drawAccessScreen(granted, uid_hex, ts, false);
    lastFullRefresh = millis();
  }

  delay(600); // pequeña pausa para evitar múltiples lecturas idénticas instantáneas
}

Explicación breve de partes clave:
– Whitelist mediante SHA‑256: evita exponer UIDs en claro en firmware/FS; puedes generar los hashes desde un PC y pegarlos en WHITELIST_SHA256.
– PN532 por SPI: usa el bus VSPI compartido con el E‑Paper; cada dispositivo tiene su CS (GPIO5 para E‑Paper y GPIO15 para PN532).
– E‑Paper con GxEPD2 (SSD1680): renderiza texto con fuentes GFX. Se usan actualizaciones parciales para respuesta fluida y periódicas completas para eliminar ghosting.
– NTP via WiFi: se sincroniza hora al inicio si hay red; se usa en logs y en pantalla.
– LittleFS: almacena un CSV con los registros de accesos.
– LED onboard: feedback simple de acceso concedido/denegado.

Script auxiliar (opcional) para generar SHA‑256 de UIDs

Úsalo en tu PC para convertir UIDs (en hex) a hash SHA‑256 y pegarlos en WHITELIST_SHA256.

# uid2sha256.py
import sys, binascii, hashlib

def main():
    if len(sys.argv) < 2:
        print("Uso: python uid2sha256.py <uid_hex> [<uid_hex> ...]")
        print("Ejemplo: python uid2sha256.py 04a224b9c13280")
        sys.exit(1)
    for uid_hex in sys.argv[1:]:
        uid_hex = uid_hex.lower().strip()
        try:
            uid_bytes = binascii.unhexlify(uid_hex)
        except Exception as e:
            print(f"UID inválido: {uid_hex} ({e})")
            continue
        sha = hashlib.sha256(uid_bytes).hexdigest()
        print(f"UID={uid_hex}  SHA256={sha}")

if __name__ == "__main__":
    main()

Ejemplo:
– python uid2sha256.py 04a224b9c13280
– Copia el sha256 resultante a WHITELIST_SHA256 en main.cpp.

Compilación / flash / ejecución

Usaremos PlatformIO en modo CLI para reproducibilidad total.

1) Crear proyecto
– mkdir nfc-epaper-access-control
– cd nfc-epaper-access-control
– pio project init –board nodemcu-32s

2) Sustituir/crear archivos
– Copia el contenido de platformio.ini (arriba) a ./platformio.ini
– Crea ./src/main.cpp con el código mostrado

3) Instalar dependencias y compilar
– pio run

4) Enumerar puertos serie
– pio device list
– Windows: COM3/COMx (CP210x/CH340)
– Linux: /dev/ttyUSB0 (CP210x) o /dev/ttyACM0
– macOS: /dev/tty.SLAB_USBtoUART

5) Grabar firmware
– pio run -t upload –upload-port
– Ejemplo Windows: pio run -t upload –upload-port COM5
– Ejemplo Linux/macOS: pio run -t upload –upload-port /dev/ttyUSB0

6) Abrir monitor serie
– pio device monitor -b 115200 –port

7) Verificación de paquetes (opcional)
– pio pkg list
– pio pkg show framework-arduinoespressif32
– pio pkg show platformio/toolchain-xtensa-esp32

Notas:
– Si “Failed to connect to ESP32”, mantén pulsado BOOT y suéltalo cuando empiece el upload (o pulsa EN/RESET tras iniciar el proceso).
– Asegúrate de haber editado WIFI_SSID y WIFI_PASSWORD en main.cpp (puedes dejarlo sin red; el proyecto funciona igual y solo no tendrá hora real).

Validación paso a paso

1) Alimentación y conexión:
– Conecta el NodeMCU-32S al PC.
– Verifica en la tabla de pines que E‑Paper y PN532 están conectados a 3V3 y GND correctamente, y los pines CS/DC/RST/BUSY/SS según la tabla.

2) Arranque y logs por serie:
– En el monitor (115200), debes ver:
– [nfc-epaper-access-control] Inicio
– [WiFi] Conectando a (si configurado)
– [NTP] Sincronizado (si la red responde)
– [FS] LittleFS montado…
– [PN532] Chip PN5xx / Firmware x.y
– [Sistema] Listo. Acerca credencial NFC tipo A al PN532.

3) Pantalla E‑Paper inicial:
– Debe mostrar título, UID —-, la hora (si hay NTP) y el pie “Waveshare 2.9″ (SSD1680)”.

4) Presenta una tarjeta/lavero NFC:
– Acerca a ~2–4 cm del PN532.
– En menos de 1 s, la pantalla debe actualizarse parcialmente mostrando:
– UID: en hex
– Hora
– ACCESS GRANTED si su SHA‑256 está en whitelist
– ACCESS DENIED en caso contrario
– LED en GPIO2:
– Encendido fijo ~800 ms si concedido
– 3 parpadeos cortos si denegado

5) Registro de acceso:
– Reinicia y entra en modo “FS inspect” (pequeña rutina temporal o lee con LittleFS a través de un sketch; alternativamente, añade temporalmente un bloque que imprima /access_log.csv en setup).
– Debe existir /access_log.csv con entradas tipo:
– 2025-05-01 12:34:56,04a224b9c13280,248d6a…,1

6) Prueba ghosting:
– Ojo a posibles sombras tras ~10–20 operaciones parciales seguidas.
– El firmware fuerza un refresco completo cada 10 lecturas o ~60 s para limpiar ghosting.

7) Pruebas sin WiFi:
– Si no hay red, el sistema funciona igual. La hora será “1970-01-01 00:00:00” hasta que haya NTP.

8) Prueba de whitelist:
– Genera el SHA‑256 del UID con el script Python y añádelo al arreglo WHITELIST_SHA256.
– Compila y sube. Vuelve a probar: ahora esa tarjeta debe recibir ACCESS GRANTED.

Troubleshooting (errores típicos y soluciones)

1) No aparece puerto serie / no sube firmware
– Windows: instala driver CP210x (o CH340). Cambia cable USB por uno de datos.
– Linux: agrega tu usuario a grupo dialout, udev rules, o usa sudo temporalmente.
– “Failed to connect to ESP32”: mantén BOOT presionado al iniciar upload y suelta cuando empiece, o pulsa EN/RESET justo después.
– Revisa que el switch de alimentación de la placa (si lo tuviera) esté en ON.

2) “[PN532] No se detecto el PN532”
– Verifica que el PN532 está en modo SPI (algunas placas tienen switch/puentes SEL).
– Comprueba SS en GPIO15 y el cableado SCK/MOSI/MISO compartiendo VSPI.
– Alimentación: usa 3V3 estable; evita cables flojos.
– Asegúrate de no usar IRQ si el sketch no lo configura (nuestro ejemplo usa polling).

3) E‑Paper bloqueado en “BUSY” (no actualiza)
– BUSY mal cableado (GPIO4 en este diseño). Verifica continuidad y pin correcto.
– DC/RST invertidos: revisa DC (GPIO17) y RST (GPIO16).
– Usa el modelo adecuado de GxEPD2: GxEPD2_290 corresponde a SSD1680 2.9″ 296×128.
– Alimentación insuficiente durante refresco: intenta refresco con USB directo a PC o cargador 5V 1A.

4) Ghosting persistente en la pantalla
– Aumenta frecuencia de refrescos completos (por ejemplo cada 5 operaciones).
– Evita texto en posiciones que cambian píxel a píxel en parciales excesivos.
– Llama a drawAccessScreen(…, false) después de una serie de parciales intensos.

5) “Brownout detector triggered”
– Fuente USB débil o cable demasiado largo/fino. Cambia a puerto USB de mayor potencia o cargador 5V 2A.
– Evita alimentar otros módulos de alto consumo desde el 3V3 si no es necesario.

6) No se sincroniza NTP
– Revisa SSID/clave; verifica que el firewall permite NTP/UDP 123.
– Cambia servidor NTP (por ejemplo “time.google.com”, “es.pool.ntp.org”).
– Asegura buena potencia de señal WiFi.

7) No se imprime/crea access_log.csv
– Asegúrate de LittleFS.begin(true) en setup; se formatea automáticamente si no existe.
– Comprueba espacio: aunque improbable en pruebas, evita generar archivos demasiado grandes sin depuración.

8) El UID se muestra pero nunca “GRANTED”
– Verifica que el SHA‑256 del UID está correcto:
– Cuidado con mayúsculas/minúsculas; nuestro código usa hex minúscula.
– Asegúrate de no añadir espacios/nuevas líneas al copiar el hash.
– Algunas tarjetas tienen UIDs de longitud distinta (4/7 bytes). El hash depende exactamente de los bytes y el orden; usa el script provisto.

Mejoras / variantes

  • Persistencia de whitelist:
  • Cargar/guardar desde LittleFS en JSON/CSV para no recompilar al añadir tarjetas.
  • Herramienta de administración por puerto serie o web (microservidor HTTP en el ESP32).
  • Seguridad avanzada:
  • HMAC‑SHA256 con “salt” y clave única por instalación; evita incluso ataques de diccionario sobre UIDs.
  • Generar códigos de un solo uso (OTP) mostrados temporalmente en E‑Paper tras verificación NFC.
  • UX del panel:
  • Añadir iconografía o inversos de color en respuestas (negro sobre blanco y viceversa).
  • Temporizador de autoapagado y despertar por botón (profundizar en consumo).
  • Red y backend:
  • Sincronizar logs a un servidor remoto (HTTPS con WiFiClientSecure).
  • Integración con MQTT para auditoría centralizada.
  • Hardware:
  • Añadir un relé/SSR controlado por ESP32 al conceder acceso (con las debidas protecciones).
  • Añadir buzzer piezoactivo para feedback audible (GPIO dedicado).
  • Modo offline robusto:
  • RTC externo (p.ej., DS3231) para mantener hora sin NTP.
  • Cifrado de whitelist en flash y bloqueo por “secure boot”/flash encryption (ecosistema ESP-IDF).

Checklist de verificación

  • [ ] He instalado PlatformIO Core 6.1.13 y Python 3.11.6.
  • [ ] He creado el proyecto con board = nodemcu-32s y platform = espressif32 @ 6.6.0.
  • [ ] He pegado platformio.ini con librerías y paquetes en las versiones indicadas.
  • [ ] He cableado correctamente el E‑Paper (SSD1680) a GPIO23/18/5/17/16/4 y 3V3/GND.
  • [ ] He cableado el PN532 (SPI) a GPIO23/18/19/15 y 3V3/GND, y está en modo SPI.
  • [ ] He configurado WIFI_SSID/WIFI_PASSWORD (opcional) en main.cpp.
  • [ ] He generado y añadido el SHA‑256 de al menos un UID a WHITELIST_SHA256.
  • [ ] La compilación pio run finaliza sin errores.
  • [ ] La carga pio run -t upload se completa; el monitor serie muestra el banner de inicio.
  • [ ] Al acercar un tag NFC se muestra UID y “ACCESS GRANTED/DENIED” en el E‑Paper.
  • [ ] Se registra el evento en /access_log.csv con timestamp, UID y resultado.
  • [ ] He verificado refrescos completos periódicos (ghosting controlado).

Con este caso práctico “nfc-epaper-access-control” has integrado lectura NFC con PN532, render en e‑paper Waveshare 2.9″ (SSD1680), y control de acceso con verificación criptográfica en un ESP32 NodeMCU-32S, cuidando reproducibilidad con PlatformIO y un pipeline de build/flash/monitor determinista.

Encuentra este producto y/o libros sobre este tema en Amazon

Ir a Amazon

Como afiliado de Amazon, gano con las compras que cumplan los requisitos. Si compras a través de este enlace, ayudas a mantener este proyecto.

Quiz rápido

Pregunta 1: ¿Cuál es la versión recomendada de Python para el proyecto?




Pregunta 2: ¿Qué herramienta se utiliza para la verificación de versiones en PlatformIO?




Pregunta 3: ¿Cuál es la versión de PlatformIO Core recomendada?




Pregunta 4: ¿Qué controlador USB suele integrar el NodeMCU-32S?




Pregunta 5: ¿Qué comando se utiliza para actualizar los paquetes en PlatformIO?




Pregunta 6: ¿Cuál es la versión de la Toolchain GCC Xtensa para ESP32 requerida?




Pregunta 7: ¿Qué versión de OpenOCD es opcional para la depuración?




Pregunta 8: ¿Qué versión del Framework Arduino-ESP32 se debe usar?




Pregunta 9: ¿Qué archivo se debe crear para agregar una regla udev en Linux?




Pregunta 10: ¿Cuál es la versión mínima recomendada del controlador CP210x para Windows?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Ingeniero Superior en Electrónica de Telecomunicaciones e Ingeniero en Informática (titulaciones oficiales en España).

Sígueme:


Caso práctico: Control PID de motor DC AS5600 + TB6612 ESP32

Caso práctico: Control PID de motor DC AS5600 + TB6612 ESP32 — hero

Objetivo y caso de uso

Qué construirás: Un sistema de control de velocidad PID para un motor DC utilizando un ESP32 DevKitC, un controlador TB6612FNG y un sensor AS5600 para la retroalimentación de posición.

Para qué sirve

  • Control preciso de la velocidad de un motor DC en aplicaciones robóticas.
  • Regulación de la posición en sistemas de automatización industrial.
  • Implementación de sistemas de control en vehículos eléctricos pequeños.
  • Optimización de procesos en impresoras 3D para mantener la velocidad constante.

Resultado esperado

  • Latencia de control inferior a 50 ms en la respuesta del motor.
  • Estabilidad del sistema con un error de seguimiento menor al 5% en condiciones de carga variable.
  • Capacidad de manejar hasta 2 A de corriente sin sobrecalentamiento en el TB6612FNG.
  • Frecuencia de actualización de datos del sensor AS5600 de 100 Hz para un control preciso.

Público objetivo: Ingenieros y desarrolladores de sistemas embebidos; Nivel: Avanzado

Arquitectura/flujo: Sensor AS5600 → ESP32 DevKitC → Controlador TB6612FNG → Motor DC

Nivel: Avanzado

Prerrequisitos

Sistema operativo y herramientas

  • Windows 11 23H2 / Ubuntu 22.04.4 LTS / macOS 14 (Sonoma). Se recomienda 64 bits.
  • Python 3.11.x (solo si usas CLI de PlatformIO o scripts auxiliares).
  • Git 2.44+ (opcional, para versionado del proyecto).
  • Drivers USB del ESP32 DevKitC (según el chip USB-serie del módulo):
  • CP210x (Silicon Labs) v10.1.10 (Windows) / integrado en macOS.
  • CH340 (WCH) v3.6 (Windows) si tu DevKitC fuese variante con CH34x.

Toolchain exacta (PlatformIO)

  • PlatformIO Core: 6.1.16
  • VSCode: 1.95.x con extensión PlatformIO IDE 3.3.x (recomendado para entorno gráfico).
  • Plataforma ESP32 en PlatformIO:
  • platform-espressif32: 6.5.0
  • framework-arduinoespressif32: 3.20014.0 (Arduino-ESP32 2.0.14)
  • toolchain-xtensa32: 2.80400.0 (xtensa-esp32-elf-gcc 8.4.0)
  • tool-esptoolpy: 1.40501.0 (esptool.py 4.5.1)

Verificación rápida de versiones:
– PlatformIO Core:
– pio –version → Debe mostrar 6.1.16
– Paquetes (una vez inicializado el proyecto):
– pio pkg list -e esp32dev → Debe listar versiones fijadas arriba

Nota: fijaremos estas versiones explícitamente en platformio.ini para máxima reproducibilidad.

Materiales

  • 1x ESP32 DevKitC (módulo ESP32-WROOM-32).
  • 1x Driver dual H-Bridge TB6612FNG (usaremos el canal A).
  • 1x Encoder magnético AS5600 (módulo I2C, dirección 0x36).
  • 1x Motor DC con imán en el eje para el AS5600.
  • 1x Fuente de alimentación para motor: 6–12 V DC, 2 A (según el motor).
  • Jumpers Dupont macho-macho.
  • 2x Resistencias de 4.7 kΩ (pull-ups I2C a 3.3 V).
  • Protoboard (opcional, recomendado).
  • 1x Cable USB micro/USB-C (según tu DevKitC).
  • Cables para conectar el motor a AO1/AO2 del TB6612FNG.

Observaciones:
– Todo el control de velocidad (pid-dc-motor-encoder-velocity) se implementa con ESP32 DevKitC + TB6612FNG + AS5600; no se requieren otros sensores.
– Mantén la masa común: GND de ESP32, TB6612FNG y AS5600 deben estar unidos.

Preparación y conexión

Recomendaciones generales

  • AS5600 en modo I2C a 3.3 V. No mezclar 5 V en el bus I2C del ESP32.
  • El TB6612FNG requiere dos alimentaciones:
  • VM: alimentación del motor (6–12 V típico).
  • VCC: lógica del driver (3.3 V del ESP32).
  • Conecta STBY del TB6612FNG a un GPIO del ESP32 (habilitaremos/inhabilitaremos el puente H por software).
  • PWM a 20 kHz mediante LEDC del ESP32 para minimizar ruido audible.
  • SDA/SCL con pull-ups a 3.3 V de 4.7 kΩ si tu módulo AS5600 no los incluye.

Tabla de conexiones

Componente Pin Conectar a ESP32 DevKitC GPIO/Alimentación Notas
TB6612FNG VM Fuente motor + 6–12 V según motor
TB6612FNG VCC 3.3 V 3V3 Lógica a 3.3 V
TB6612FNG GND GND GND Masa común con ESP32 y AS5600
TB6612FNG STBY GPIO33 GPIO33 Alto para habilitar
TB6612FNG AIN1 GPIO26 GPIO26 Dirección A
TB6612FNG AIN2 GPIO27 GPIO27 Dirección A
TB6612FNG PWMA GPIO25 GPIO25 (LEDC) PWM velocidad A
TB6612FNG AO1 Terminal motor 1 Salida a motor
TB6612FNG AO2 Terminal motor 2 Salida a motor
AS5600 (I2C) VCC 3.3 V 3V3 No usar 5 V
AS5600 (I2C) GND GND GND Masa común
AS5600 (I2C) SDA SDA GPIO21 Pull-up 4.7 kΩ a 3.3 V si hace falta
AS5600 (I2C) SCL SCL GPIO22 Pull-up 4.7 kΩ a 3.3 V si hace falta

Notas adicionales:
– Si tu módulo AS5600 incluye resistencias pull-up integradas, no añadiras externas.
– Asegúrate de polaridad correcta en VM y VCC. No mezclar VM (motor) con 3.3 V (lógica).

Código completo

A continuación se presenta un proyecto funcional con:
– platformio.ini con versiones exactas de la toolchain.
– src/main.cpp con un bucle de control de velocidad a 1 kHz usando FreeRTOS, lectura del AS5600 por I2C, PID de velocidad y modulación PWM con LEDC hacia el TB6612FNG.
– Interfaz serie para ajuste online de setpoint y ganancias PID.

platformio.ini

; Proyecto PlatformIO para ESP32 DevKitC + TB6612FNG + AS5600
; Toolchain EXACTA fijada para reproducibilidad

[env:esp32dev]
platform = espressif32@6.5.0
board = esp32dev
framework = arduino
monitor_speed = 115200
upload_speed = 460800
monitor_filters = time

; Fijamos paquetes exactos:
platform_packages =
  tool-esptoolpy@1.40501.0
  toolchain-xtensa32@2.80400.0
  framework-arduinoespressif32@3.20014.0

build_flags =
  -DCORE_DEBUG_LEVEL=1
  -DARDUINO_USB_CDC_ON_BOOT=0

; Puerto serie (opcional; en Windows puede ser COMx)
; upload_port = /dev/ttyUSB0
; monitor_port = /dev/ttyUSB0

src/main.cpp

#include <Arduino.h>
#include <Wire.h>

// ===================== Hardware mapping (ESP32 DevKitC + TB6612FNG + AS5600) =====================
static constexpr uint8_t PIN_TB_STBY = 33;
static constexpr uint8_t PIN_TB_PWMA = 25; // LEDC PWM
static constexpr uint8_t PIN_TB_AIN1 = 26;
static constexpr uint8_t PIN_TB_AIN2 = 27;

static constexpr uint8_t PIN_I2C_SDA = 21;
static constexpr uint8_t PIN_I2C_SCL = 22;

static constexpr uint8_t AS5600_ADDR = 0x36;
static constexpr uint8_t AS5600_REG_ANGLE_H = 0x0E; // High/Low bytes 12-bit angle
static constexpr uint8_t AS5600_REG_ANGLE_L = 0x0F;
static constexpr uint8_t AS5600_REG_STATUS  = 0x0B; // Magnet detection

// ===================== PWM (LEDC) =====================
static constexpr uint32_t PWM_FREQ_HZ = 20000; // 20 kHz
static constexpr uint8_t  PWM_CHANNEL = 0;
static constexpr uint8_t  PWM_TIMER_BITS = 12; // duty 0..4095
static constexpr uint16_t PWM_MAX_DUTY = (1u << PWM_TIMER_BITS) - 1;

// ===================== Control rates =====================
static constexpr uint32_t CTRL_HZ = 1000;     // 1 kHz control loop
static constexpr float    CTRL_DT = 1.0f / CTRL_HZ;

// ===================== PID =====================
struct PID {
  float kp{0.8f};
  float ki{25.0f};
  float kd{0.000f};
  float i{0.0f};
  float prevErr{0.0f};
  float outMin{-1.0f};
  float outMax{+1.0f};
  float awTau{0.1f}; // anti-windup back-calculation factor
} pid;

// Setpoint en RPM (interfaz usuario). Internamente trabajaremos en rad/s
volatile float g_setpoint_rpm = 180.0f;

// Filtro simple de velocidad (exponencial)
float vel_alpha = 0.2f; // 0..1, mayor = más rápido, menos filtrado

// Datos compartidos para telemetría
volatile float g_meas_rpm = 0.0f;
volatile float g_cmd_u = 0.0f; // salida normalizada -1..+1
volatile float g_error_rpm = 0.0f;
volatile bool  g_magnet_ok = false;

// ===================== Utilidades AS5600 =====================
bool i2cReadBytes(uint8_t dev, uint8_t reg, uint8_t *buf, size_t len) {
  Wire.beginTransmission(dev);
  Wire.write(reg);
  if (Wire.endTransmission(false) != 0) return false; // repeated start
  size_t n = Wire.requestFrom((int)dev, (int)len);
  if (n != len) return false;
  for (size_t i = 0; i < len; ++i) {
    buf[i] = Wire.read();
  }
  return true;
}

uint16_t as5600ReadAngle12() {
  uint8_t buf[2] = {0};
  if (!i2cReadBytes(AS5600_ADDR, AS5600_REG_ANGLE_H, buf, 2)) {
    return 0;
  }
  uint16_t raw = ((uint16_t)buf[0] << 8) | buf[1];
  return (raw & 0x0FFF); // 12-bit
}

bool as5600MagnetDetected() {
  uint8_t s = 0;
  if (!i2cReadBytes(AS5600_ADDR, AS5600_REG_STATUS, &s, 1)) return false;
  // STATUS bits: MD=Magnet detected(5), ML=too weak(3), MH=too strong(4)
  bool md = s & (1 << 5);
  bool tooWeak = s & (1 << 3);
  bool tooStrong = s & (1 << 4);
  // Consideramos OK si MD y no ML/MH
  return md && !tooWeak && !tooStrong;
}

// ===================== Conversión ángulo/velocidad =====================
// 4096 ticks por vuelta; velocidad en RPM a partir de delta_tics / dt.
static constexpr float TICKS_PER_REV = 4096.0f;
static constexpr float TWO_PI = 6.283185307179586f;

float ticksPerSecToRPS(float tps) {
  return tps / TICKS_PER_REV; // rev/s
}
float rpsToRPM(float rps) {
  return rps * 60.0f;
}
float rpmToRPS(float rpm) {
  return rpm / 60.0f;
}

// ===================== TB6612FNG Motor Driver =====================
void motorStandby(bool enable) {
  digitalWrite(PIN_TB_STBY, enable ? HIGH : LOW);
}

void motorDrive(float u) {
  // u en [-1, +1]
  float uu = constrain(u, -1.0f, +1.0f);
  int duty = (int)(fabsf(uu) * PWM_MAX_DUTY);

  // Compensación de zona muerta (~8 %) para arrancar motores pequeños
  const int dead_zone = (int)(0.08f * PWM_MAX_DUTY);
  if (duty > 0) duty = max(duty, dead_zone);

  if (uu >= 0.0f) {
    digitalWrite(PIN_TB_AIN1, HIGH);
    digitalWrite(PIN_TB_AIN2, LOW);
  } else {
    digitalWrite(PIN_TB_AIN1, LOW);
    digitalWrite(PIN_TB_AIN2, HIGH);
  }
  ledcWrite(PWM_CHANNEL, duty);
}

// ===================== PID compute =====================
float pidCompute(PID &c, float set_rps, float meas_rps, float dt) {
  float err = set_rps - meas_rps;
  // Proporcional
  float p = c.kp * err;
  // Derivativa (sobre error)
  float d = c.kd * (err - c.prevErr) / dt;
  // Integrativa con anti-windup por back-calculation
  float u_unsat = p + c.i + d;
  float u_sat = constrain(u_unsat, c.outMin, c.outMax);
  float aw = c.awTau * (u_sat - u_unsat); // si satura, corrige integrador
  c.i += c.ki * err * dt + aw;
  c.i = constrain(c.i, c.outMin, c.outMax);
  c.prevErr = err;
  return constrain(p + c.i + d, c.outMin, c.outMax);
}

// ===================== Tarea de control (1 kHz) =====================
TaskHandle_t g_ctrlTask = nullptr;

void controlTask(void *arg) {
  // Estado para unwrap de ángulo
  int32_t accumTicks = 0;
  uint16_t prev = as5600ReadAngle12();
  accumTicks = prev;
  uint32_t last_us = micros();

  // Filtro de velocidad
  float vel_rps_filt = 0.0f;

  // Programación periódica
  TickType_t lastWake = xTaskGetTickCount();

  for (;;) {
    vTaskDelayUntil(&lastWake, pdMS_TO_TICKS(1)); // 1 ms

    bool magnetOk = as5600MagnetDetected();
    g_magnet_ok = magnetOk;

    uint16_t now = as5600ReadAngle12();
    // unwrap
    int16_t delta = (int16_t)now - (int16_t)prev;
    if (delta > 2048) delta -= 4096;
    if (delta < -2048) delta += 4096;
    accumTicks += delta;
    prev = now;

    uint32_t now_us = micros();
    float dt = (now_us - last_us) / 1e6f;
    if (dt < 1e-6f) dt = CTRL_DT; // fallback
    last_us = now_us;

    // Velocidad en ticks/seg
    float tps = (float)delta / dt;
    float rps = ticksPerSecToRPS(tps);
    // Filtro exponencial
    vel_rps_filt = vel_alpha * rps + (1.0f - vel_alpha) * vel_rps_filt;

    // Setpoint y PID
    float set_rps = rpmToRPS(g_setpoint_rpm);
    // Seguridad si no hay imán correcto: poner setpoint 0 y salida 0
    float u = 0.0f;
    if (magnetOk) {
      u = pidCompute(pid, set_rps, vel_rps_filt, dt);
    } else {
      pid.i = 0.0f; pid.prevErr = 0.0f; u = 0.0f;
    }
    g_cmd_u = u;

    // Aplicar al motor
    motorDrive(u);

    // Telemetría compartida (no crítica, precisión best-effort)
    g_meas_rpm = rpsToRPM(vel_rps_filt);
    g_error_rpm = g_setpoint_rpm - g_meas_rpm;
  }
}

// ===================== Interfaz serie simple =====================
void printHelp() {
  Serial.println(F("# Comandos:"));
  Serial.println(F("#   sp <rpm>        : fija setpoint (RPM)"));
  Serial.println(F("#   kp <val>        : Kp"));
  Serial.println(F("#   ki <val>        : Ki"));
  Serial.println(F("#   kd <val>        : Kd"));
  Serial.println(F("#   alpha <0..1>    : filtro velocidad (exponencial)"));
  Serial.println(F("#   stop            : setpoint = 0"));
  Serial.println(F("#   status          : imprime estado/gains"));
  Serial.println(F("# Formato log: t_ms, set_rpm, meas_rpm, err_rpm, u, magnet_ok"));
}

void printStatus() {
  Serial.printf("# Kp=%.5f Ki=%.5f Kd=%.5f alpha=%.3f SP=%.2f RPM\r\n",
                pid.kp, pid.ki, pid.kd, vel_alpha, g_setpoint_rpm);
}

void handleSerial() {
  if (!Serial.available()) return;
  String line = Serial.readStringUntil('\n');
  line.trim();
  if (line.length() == 0) return;

  if (line.startsWith("sp ")) {
    float v = line.substring(3).toFloat();
    g_setpoint_rpm = v;
    Serial.printf("# SP=%.2f RPM\r\n", g_setpoint_rpm);
  } else if (line.startsWith("kp ")) {
    pid.kp = line.substring(3).toFloat();
    printStatus();
  } else if (line.startsWith("ki ")) {
    pid.ki = line.substring(3).toFloat();
    printStatus();
  } else if (line.startsWith("kd ")) {
    pid.kd = line.substring(3).toFloat();
    printStatus();
  } else if (line.startsWith("alpha ")) {
    vel_alpha = constrain(line.substring(6).toFloat(), 0.0f, 1.0f);
    printStatus();
  } else if (line == "stop") {
    g_setpoint_rpm = 0.0f;
    Serial.println("# SP=0");
  } else if (line == "status") {
    printStatus();
  } else if (line == "help" || line == "?") {
    printHelp();
  } else {
    Serial.println("# Comando no reconocido. Escribe 'help'.");
  }
}

// ===================== Setup/Loop =====================
void setup() {
  pinMode(PIN_TB_STBY, OUTPUT);
  pinMode(PIN_TB_AIN1, OUTPUT);
  pinMode(PIN_TB_AIN2, OUTPUT);

  Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL, 400000); // I2C @ 400 kHz

  Serial.begin(115200);
  delay(200);
  Serial.println("# ESP32 DevKitC + TB6612FNG + AS5600 - PID velocity control");
  Serial.println("# Toolchain: PlatformIO Core 6.1.16, espressif32 6.5.0, arduino-esp32 2.0.14");
  printHelp();

  // Configurar PWM (LEDC)
  ledcSetup(PWM_CHANNEL, PWM_FREQ_HZ, PWM_TIMER_BITS);
  ledcAttachPin(PIN_TB_PWMA, PWM_CHANNEL);

  // Motor standby inicialmente deshabilitado
  motorStandby(false);
  delay(50);
  motorStandby(true);

  // Crear tarea de control en core 1 con prioridad media-alta
  xTaskCreatePinnedToCore(controlTask, "ctrl", 4096, nullptr, 3, &g_ctrlTask, 1);
}

void loop() {
  static uint32_t lastPrint = 0;
  handleSerial();

  uint32_t now = millis();
  if (now - lastPrint >= 20) { // ~50 Hz de logging
    lastPrint = now;
    // Log sencillo: t_ms, set_rpm, meas_rpm, err_rpm, u, magnet_ok
    Serial.printf("%lu, %.2f, %.2f, %.2f, %.3f, %d\r\n",
                  (unsigned long)now,
                  g_setpoint_rpm,
                  g_meas_rpm,
                  g_error_rpm,
                  g_cmd_u,
                  g_magnet_ok ? 1 : 0);
  }
}

Script opcional para graficar (local)

Si te interesa visualizar la respuesta en tiempo real (no es obligatorio), puedes usar este script Python (requiere pyserial y matplotlib). Ajusta el puerto serie.

# tools/plot_pid.py
import serial, time
import matplotlib.pyplot as plt
from collections import deque

PORT = "/dev/ttyUSB0"   # o COMx en Windows
BAUD = 115200

ser = serial.Serial(PORT, BAUD, timeout=0.1)
time.sleep(2)

buf_t, buf_sp, buf_meas = deque(maxlen=1000), deque(maxlen=1000), deque(maxlen=1000)
plt.ion()
fig, ax = plt.subplots()
ln_sp, = ax.plot([], [], label='Setpoint RPM')
ln_me, = ax.plot([], [], label='Medida RPM')
ax.set_xlabel('t (s)')
ax.set_ylabel('RPM')
ax.legend()
ax.grid(True)

t0 = time.time()
while True:
    try:
        line = ser.readline().decode(errors='ignore').strip()
        if ',' in line and not line.startswith('#'):
            parts = [p.strip() for p in line.split(',')]
            if len(parts) >= 3:
                t_ms = float(parts[0])
                sp = float(parts[1])
                me = float(parts[2])
                buf_t.append((t_ms/1000.0) - (t0))
                buf_sp.append(sp)
                buf_meas.append(me)
                ln_sp.set_data(list(buf_t), list(buf_sp))
                ln_me.set_data(list(buf_t), list(buf_meas))
                ax.relim(); ax.autoscale_view()
                plt.pause(0.01)
    except KeyboardInterrupt:
        break

Compilación, grabación y ejecución

Inicialización del proyecto

  • Crea el proyecto con PlatformIO (en una carpeta vacía):
  • pio project init –board esp32dev
  • Sustituye el contenido de platformio.ini por el proporcionado arriba (fija versiones).
  • Crea src/main.cpp con el código proporcionado.
  • Opcional: añade tools/plot_pid.py si usarás la gráfica.

Comandos exactos

  • Compilar:
  • pio run
  • Subir firmware al ESP32 DevKitC:
  • pio run -t upload
  • Abrir monitor serie (115200 baudios):
  • pio device monitor -b 115200
  • Todo encadenado:
  • pio run -t upload && pio device monitor -b 115200

Verificación de paquetes y versiones instaladas para este entorno:
– pio pkg list -e esp32dev

Ejemplo de salida esperada (resumen):
– framework-arduinoespressif32 3.20014.0
– toolchain-xtensa32 2.80400.0
– tool-esptoolpy 1.40501.0
– platform-espressif32 6.5.0

Validación paso a paso

1) Comprobación eléctrica previa

  • Con motor desconectado (AO1/AO2 sin motor), enciende el sistema con VM presente y ESP32 por USB.
  • Monitor serie a 115200. Debes ver:
  • Línea de bienvenida con “ESP32 DevKitC + TB6612FNG + AS5600 – PID velocity control”.
  • Toolchain listada.
  • Ayuda con comandos.

Si no hay errores, conecta el motor a AO1/AO2 del TB6612FNG y coloca el imán del AS5600 alineado con el eje (distancia y centrado adecuados).

2) Estado del imán (AS5600)

  • Observa el último campo del log periódico: “magnet_ok”.
  • 1: AS5600 detecta imán en rango válido.
  • 0: demasido débil/fuerte o no detectado.
  • Si aparece 0:
  • Reajusta la distancia entre sensor y magneto.
  • Asegura alimentación 3.3 V adecuada y pull-ups I2C.
  • Consulta Troubleshooting.

3) Primer giro con setpoint bajo

  • Introduce por monitor serie:
  • sp 60
  • Observa:
  • El motor debe empezar a girar suavemente.
  • Log aproximado:
    • t_ms, set_rpm, meas_rpm, err_rpm, u, magnet_ok
    • 1234, 60.00, 5.00, 55.00, 0.25, 1
    • … (debe converger hacia meas_rpm ≈ 60)

Si hay un leve retardo de arranque, es normal: la compensación de zona muerta se encarga de vencer el rozamiento.

4) Escalón a velocidad media

  • Cambia a:
  • sp 180
  • Esperado:
  • Aumento de “u” en el log, un pico de error y convergencia en menos de 1–2 s (dependiendo del motor).
  • Overshoot razonable si Kp alto (p.ej., +5–15 %). Si es excesivo, reduce Kp o aumenta D.
  • Estabilidad:
  • El valor meas_rpm debe mantenerse cercano al setpoint bajo cargas leves.
  • Aplica una carga con el dedo (con cuidado) y observa cómo el PID compensa elevando “u”.

5) Ajuste de ganancias PID

  • Si el sistema:
  • Oscila: reduce Kp o aumenta Kd ligeramente (p.ej., kd 0.0005, luego 0.001).
  • Es lento: aumenta Kp gradualmente (0.8 → 1.2 → 1.5) y luego sube Ki (25 → 35).
  • Tiene mucho error estacionario: aumenta Ki en pasos pequeños (±5) con cuidado.
  • Recuerda:
  • Demasiada Ki provoca oscilaciones y saturación (u pega en ±1).
  • Usa kd con moderación si el ruido de velocidad es elevado. Aumenta alpha (p.ej., alpha 0.3) para filtrar más rápido.

6) Validación cuantitativa

  • Paso de 0 → 120 RPM → 240 RPM, mide:
  • Tiempo de establecimiento: < 1.5 s (objetivo).
  • Overshoot: < 10–15 %.
  • Error en régimen: < 3–5 % sin carga.
  • Log representativo:
  • En reposo: “u” ~ 0, meas_rpm ~ 0.
  • A 120 RPM: error se acerca a 0, “u” se estabiliza.
  • Con carga transitoria: “u” sube y luego regresa a su valor precedente al retirar la carga.

7) Prueba de parada controlada

  • stop
  • Esperado:
  • setpoint = 0.
  • El motor desacelera hasta detenerse, con “u” → 0.
  • Sin imán (magnet_ok = 0), el control corta salida por seguridad y resetea integrador.

8) Opcional: gráfica

  • Ejecuta:
  • python3 tools/plot_pid.py
  • Cambia setpoints y observa en tiempo real setpoint/medida.

Troubleshooting

1) Motor no gira
– Verifica STBY del TB6612FNG está en alto (GPIO33 configurado y en HIGH).
– Comprueba VM (motor supply) presente y GND común con ESP32.
– Revisa AIN1/AIN2 invertidos o desconectados. Invierte los cables del motor si gira al revés.
– Aumenta setpoint (p.ej., sp 120) para vencer rozamiento si el motor es duro.

2) I2C no responde (velocidad medida 0 y magnet_ok = 0)
– Chequea pull-ups a 3.3 V en SDA/SCL (4.7 kΩ), o confirma que el módulo AS5600 ya los trae.
– Verifica el cableado SDA→GPIO21, SCL→GPIO22.
– Asegura VCC del AS5600 a 3.3 V (no 5 V).
– Revisa que la dirección 0x36 es correcta (evitar módulos clon con otra address rara).

3) Lecturas de velocidad erráticas (picos o ruido excesivo)
– Aumenta alpha (p.ej., alpha 0.3–0.5) para filtrar la velocidad.
– Reduce kd a 0 si no necesitas término derivativo.
– Verifica alineación y distancia del imán; evita vibraciones mecánicas.

4) Sobreoscilación persistente
– Reduce kp y/o ki. Empieza con kd = 0 y añade derivativa en incrementos pequeños (0.0005, 0.001).
– Confirma que la frecuencia de control (1 kHz) es estable (no satures el puerto serie con logs; ya está a ~50 Hz).

5) Ruido audible/pérdidas de par a bajas velocidades
– Asegura PWM a 20 kHz (LEDC configurado como en el código): build limpio y verificación en runtime.
– Ajusta dead_zone (8 %) si tu motor requiere más duty para arrancar.

6) Reseteos del ESP32 al iniciar el motor
– La fuente de motor produce caídas de VM/GND: usa una fuente estable y separa el 5V/3.3V de lógica del ESP32.
– Mantén GND común y añade condensadores de bulk (p.ej., 470–1000 µF) cerca del TB6612FNG.

7) Velocidad se invierte con setpoints positivos
– Invierte AIN1/AIN2 en el código (o invierte el cableado del motor).
– Verifica la lógica de motorDrive(u): u>=0 debe corresponder al sentido que consideras “positivo”.

8) No se puede flashear (puerto no aparece)
– Instala driver CP210x/CH34x correcto y usa un cable USB de datos.
– Selecciona el puerto correcto en platformio.ini (upload_port/monitor_port).
– Pulsa BOOT/EN del DevKitC si fuese necesario (algunas placas requieren pulsos manuales).

Mejoras/variantes

  • Sustituir LEDC por MCPWM del ESP32 para PWM de alta resolución y sincronización avanzada (no imprescindible para TB6612FNG).
  • Añadir feedforward (u_ff = kff * set_rps) para reducir esfuerzo del integrador y mejorar respuesta ante cargas.
  • Almacenamiento de parámetros PID en NVS (no volátil) para mantener ajustes tras reinicios.
  • Estimación más robusta de velocidad:
  • Promediado móvil de delta ticks con ventana deslizante.
  • Filtro de mediana para spikes.
  • Filtro de Kalman si el entorno es muy ruidoso.
  • Limitador de rampa del setpoint (slew rate) para evitar peticiones bruscas al motor.
  • Supervisión de corriente (añadiendo un shunt + ADC) para protección térmica o de sobrecarga.
  • Cambio de objetivo: control cascada posición-velocidad (outer loop de posición con inner loop de velocidad).
  • Integración con ROS2/micro-ROS para telemetría remota y control superior.

Checklist de verificación

  • [ ] Toolchain instalada: PlatformIO Core 6.1.16, espressif32 6.5.0, arduino-esp32 2.0.14 (3.20014.0).
  • [ ] Drivers USB instalados (CP210x/CH34x) y puerto serie visible.
  • [ ] platformio.ini idéntico al del tutorial (versiones fijadas).
  • [ ] Cableado revisado:
  • [ ] TB6612FNG VM a fuente motor, VCC a 3.3 V, GND común.
  • [ ] STBY a GPIO33, AIN1→GPIO26, AIN2→GPIO27, PWMA→GPIO25.
  • [ ] AS5600 SDA→GPIO21, SCL→GPIO22, VCC 3.3 V y GND; pull-ups presentes.
  • [ ] Subida de firmware sin errores (pio run -t upload).
  • [ ] Monitor serie a 115200 con mensajes de inicio y “help”.
  • [ ] magnet_ok = 1 en reposo (AS5600 detecta imán correctamente).
  • [ ] sp 60 hace girar el motor estable cerca de 60 RPM.
  • [ ] sp 180 responde con subida de velocidad y estabiliza sin oscilaciones severas.
  • [ ] stop detiene el motor y “u” → 0.
  • [ ] Ajuste de Kp/Ki/Kd probado para optimizar respuesta de tu motor.

Con este caso práctico de nivel avanzado, has implementado un control PID de velocidad completo basado en sensores magnéticos absolutos (AS5600) y un puente H TB6612FNG, ejecutado sobre un ESP32 DevKitC con una toolchain totalmente especificada para reproducibilidad. El pipeline de medición (I2C 400 kHz, unwrapping de 12 bits), estimación de velocidad filtrada, lazo de control a 1 kHz y modulación PWM a 20 kHz permite un control fino y silencioso de motores DC en aplicaciones embebidas avanzadas.

Encuentra este producto y/o libros sobre este tema en Amazon

Ir a Amazon

Como afiliado de Amazon, gano con las compras que cumplan los requisitos. Si compras a través de este enlace, ayudas a mantener este proyecto.

Quiz rápido

Pregunta 1: ¿Qué sistema operativo se recomienda para utilizar con el ESP32 DevKitC?




Pregunta 2: ¿Cuál es la versión mínima de Git recomendada?




Pregunta 3: ¿Qué driver USB es necesario si el módulo tiene un chip CP210x?




Pregunta 4: ¿Cuál es la versión de PlatformIO Core recomendada?




Pregunta 5: ¿Qué tipo de motor se menciona en el artículo?




Pregunta 6: ¿Qué resistencia se utiliza como pull-up para el I2C?




Pregunta 7: ¿Qué fuente de alimentación se recomienda para el motor?




Pregunta 8: ¿Cuál es la dirección I2C del módulo AS5600?




Pregunta 9: ¿Qué extensión se recomienda para usar con VSCode?




Pregunta 10: ¿Cuál es la versión del framework-arduinoespressif32 recomendada?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Ingeniero Superior en Electrónica de Telecomunicaciones e Ingeniero en Informática (titulaciones oficiales en España).

Sígueme:


Caso práctico: Telemetría agro LoRa bajo consumo con ESP32

Caso práctico: Telemetría agro LoRa bajo consumo con ESP32 — hero

Objetivo y caso de uso

Qué construirás: Un nodo sensor de bajo consumo para monitoreo ambiental utilizando TTGO LoRa32, BME280 y ADS1115.

Para qué sirve

  • Monitoreo de temperatura y humedad en invernaderos usando BME280.
  • Medición de niveles de agua en cultivos mediante ADS1115.
  • Transmisión de datos de sensores a través de LoRa para largas distancias.
  • Integración con sistemas de alerta temprana para condiciones ambientales adversas.

Resultado esperado

  • Datos de temperatura y humedad reportados cada 10 minutos.
  • Latencia de transmisión de datos inferior a 5 segundos.
  • Consumo de energía del nodo menor a 50 mA en modo activo.
  • Rango de transmisión efectivo de al menos 5 km en campo abierto.

Público objetivo: Ingenieros y desarrolladores en IoT; Nivel: Avanzado

Arquitectura/flujo: Nodo sensor LoRa -> Gateway LoRa -> Plataforma de monitoreo en la nube.

Nivel: Avanzado

Prerrequisitos

  • Sistemas operativos verificados
  • Windows 11 Pro 23H2 (con privilegios de administrador para instalar drivers)
  • Ubuntu 22.04.4 LTS (usuario en grupo dialout)
  • macOS 14 Sonoma

  • Toolchain exacta (probada)

  • PlatformIO Core 6.1.14 (CLI)
  • Python 3.11.6
  • Git 2.44.0
  • PlatformIO platform espressif32@6.5.0
  • Framework Arduino-ESP32 2.0.14 (resuelto por espressif32@6.5.0)
  • toolchain-xtensa-esp32 gcc 8.4.0 (instalado por la plataforma anterior)
  • esptool.py 4.6 (tool-esptoolpy suministrado por PlatformIO)
  • Librerías Arduino (versiones fijas en este proyecto)

    • sandeepmistry/LoRa@0.8.0
    • adafruit/Adafruit BME280 Library@2.2.4
    • adafruit/Adafruit Unified Sensor@1.1.14
    • adafruit/Adafruit ADS1X15@2.4.0
  • Drivers de USB–Serie

  • La TTGO LoRa32 emplea normalmente CP2102/CP2104 (Silicon Labs). Instalar el driver:
    • Windows/macOS: descargar de Silicon Labs “CP210x Universal Windows/Mac Driver”.
    • Linux: el kernel ya incluye cp210x. Añade tu usuario a dialout: sudo usermod -aG dialout $USER y reinicia sesión.
  • Si tu variante usase CH340/CH34x, instala el driver de WCH.

  • Notas de conformidad LoRa

  • Selecciona banda según tu región y normativa:
    • EU868: 868.1/868.3/868.5 MHz (duty-cycle 1% típico).
    • US915: 903.9–927.5 MHz (canales espaciados 0.2 MHz).
  • En este caso práctico se configura por defecto EU868 (868.1 MHz); puedes cambiarlo en build_flags.

Materiales

  • 1× TTGO LoRa32 (ESP32 + SX1276) con interfaz USB (modelo de LILYGO, también llamado “TTGO LoRa32 v1/v2”).
  • 1× BME280 (módulo I2C, dirección por defecto 0x76/0x77).
  • 1× ADS1115 (módulo I2C, dirección por defecto 0x48).
  • 1× Sensor capacitivo de humedad de suelo 3.3 V (salida analógica, no el resistivo).
  • 1× Batería LiPo 3.7 V (con conector JST-PH compatible con la TTGO LoRa32).
  • Resistencias para divisor de tensión de batería hacia ADS1115 (ejemplo: 100 kΩ + 100 kΩ).
  • Cables Dupont Macho–Hembra/Macho–Macho.
  • Cable USB-C o Micro-USB según tu TTGO LoRa32.
  • Opcional, para validación por radio:
  • 1× segunda TTGO LoRa32 (ESP32 + SX1276) para actuar como receptor simple.

Notas:
– El objetivo “lora-agro-telemetry-lowpower” exige minimizar consumo: usaremos deep sleep del ESP32, el SX1276 en sleep entre tramas, BME280 en modo forced y lecturas single-shot del ADS1115.

Preparación y conexión

La TTGO LoRa32 integra el SX1276 y el bus SPI ya cableado en la propia placa. Los sensores BME280 y ADS1115 se conectan por I2C. Para sensar humedad de suelo usando un sensor capacitivo, su salida analógica se conectará a la entrada A0 del ADS1115. Si se desea telemetría de batería, llevaremos la tensión VBAT a A1 del ADS1115 a través de un divisor 1:2 (por ejemplo, 100 kΩ/100 kΩ), de modo que un máximo de 4.2 V quede por debajo del rango seguro del ADC externo.

Tabla de pines, buses y direcciones

Componente/Señal TTGO LoRa32 (ESP32) Notas/Detalle
I2C SDA GPIO 21 Conectar a SDA de BME280 y ADS1115
I2C SCL GPIO 22 Conectar a SCL de BME280 y ADS1115
3V3 3V3 Alimentación sensores BME280, ADS1115 y sensor de suelo (si admite 3.3 V)
GND GND Tierra común para todos los módulos
SX1276 NSS (SS) GPIO 18 Interno a la placa; lo fijamos en el código LoRa.setPins(18,14,26)
SX1276 RST GPIO 14 Interno
SX1276 DIO0 GPIO 26 Interno
SPI SCK GPIO 5 Interno al SX1276
SPI MISO GPIO 19 Interno al SX1276
SPI MOSI GPIO 27 Interno al SX1276
ADS1115 dirección I2C 0x48 Por defecto (ADDR a GND)
BME280 dirección I2C 0x76 o 0x77 Según módulo (normalmente 0x76)
Sensor suelo -> ADS1115 A0 ADS1115 A0 Señal analógica del sensor capacitivo
VBAT dividido -> ADS1115 A1 ADS1115 A1 Divisor 100 kΩ/100 kΩ entre VBAT y GND; punto medio a A1

Conexiones paso a paso:
1. Alimentación y bus I2C:
– TTGO 3V3 → 3V3 de BME280, 3V3 de ADS1115 y VCC del sensor de suelo.
– TTGO GND → GND de BME280, GND de ADS1115 y GND del sensor de suelo.
– TTGO GPIO21 (SDA) → SDA de BME280 y SDA de ADS1115.
– TTGO GPIO22 (SCL) → SCL de BME280 y SCL de ADS1115.
2. Entradas analógicas en ADS1115:
– Sensor suelo OUT → ADS1115 A0.
– Punto medio del divisor de VBAT (entre 100 kΩ y 100 kΩ) → ADS1115 A1.
– Extremos del divisor: superior a VBAT (línea de batería de la TTGO, no al 5 V), inferior a GND.
3. Batería:
– Conecta la LiPo al conector JST de la TTGO LoRa32. La placa integra carga/gestión básica.
4. Antena:
– Conecta una antena adecuada a la TTGO (imprescindible para no dañar el SX1276).

Verificación inicial (opcional pero recomendable):
– Con un escáner I2C se deben ver 0x48 (ADS1115) y 0x76/0x77 (BME280).

Código completo

A continuación se muestra un proyecto completo para PlatformIO (framework Arduino). Implementa:
– Lectura de BME280 en modo forced (reduce consumo).
– Lectura single-shot de ADS1115 en A0 (humedad de suelo) y A1 (VBAT/2).
– Trama binaria compacta con CRC16-IBM.
– SX1276 dormido entre tramas; ESP32 en deep sleep con temporizador.
– Frecuencia, SF y TX power configurables por build_flags.
– Desactivación de WiFi/BT.

platformio.ini

Crea el proyecto y pega este contenido en platformio.ini:

; Proyecto: lora-agro-telemetry-lowpower
; Dispositivo: TTGO LoRa32 (ESP32 + SX1276) + BME280 + ADS1115
; Toolchain exacta fijada vía platform y libs

[env:ttgo-lora32-v1]
platform = espressif32@6.5.0
board = ttgo-lora32-v1
framework = arduino

; Paquetes de plataforma (se instalan automáticamente con espressif32@6.5.0)
; - toolchain-xtensa-esp32 (gcc 8.4.0)
; - tool-esptoolpy (esptool.py 4.6)
; - framework-arduinoespressif32 (Arduino-ESP32 2.0.14)

monitor_speed = 115200
monitor_filters = time, esp32_exception_decoder

lib_deps =
  sandeepmistry/LoRa@0.8.0
  adafruit/Adafruit BME280 Library@2.2.4
  adafruit/Adafruit Unified Sensor@1.1.14
  adafruit/Adafruit ADS1X15@2.4.0

; Parámetros de radio y hardware (modificables)
build_flags =
  -D LORA_FREQ_MHZ=868.1
  -D LORA_SF=7
  -D LORA_TX_PWR_DBM=14
  -D I2C_SDA=21
  -D I2C_SCL=22
  -D SOIL_CH=0
  -D VBAT_CH=1
  -D WAKE_INTERVAL_S=900
  -D REGION_EU868

Notas:
– Para US915, cambia LORA_FREQ_MHZ=915.0 y retira REGION_EU868 o define REGION_US915.
– Puedes afinar la SF (7–12) y potencia (2–20 dBm) según normativa y enlace.

src/main.cpp

Crea src/main.cpp con el siguiente código. Está documentado por bloques clave.

#include <Arduino.h>
#include <Wire.h>
#include <SPI.h>
#include <LoRa.h>
#include <WiFi.h>
#include "esp_bt.h"
#include <Adafruit_BME280.h>
#include <Adafruit_ADS1X15.h>

#ifndef LORA_FREQ_MHZ
#define LORA_FREQ_MHZ 868.1
#endif
#ifndef LORA_SF
#define LORA_SF 7
#endif
#ifndef LORA_TX_PWR_DBM
#define LORA_TX_PWR_DBM 14
#endif
#ifndef I2C_SDA
#define I2C_SDA 21
#endif
#ifndef I2C_SCL
#define I2C_SCL 22
#endif
#ifndef WAKE_INTERVAL_S
#define WAKE_INTERVAL_S 900
#endif
#ifndef SOIL_CH
#define SOIL_CH 0
#endif
#ifndef VBAT_CH
#define VBAT_CH 1
#endif

// Mapeo SX1276 en TTGO LoRa32
static constexpr int LORA_SS = 18;
static constexpr int LORA_RST = 14;
static constexpr int LORA_DIO0 = 26;

// SPI (VSPI) ya mapeado a SCK=5, MISO=19, MOSI=27 por defecto en ESP32-ARDUINO

// Sensores I2C
Adafruit_BME280 bme;
Adafruit_ADS1115 ads;

// Contador persistente en deep sleep
RTC_DATA_ATTR uint32_t rtc_seq = 0;

// CRC16-IBM (polinomio 0xA001)
uint16_t crc16_ibm(const uint8_t* data, size_t len) {
  uint16_t crc = 0xFFFF;
  for (size_t i = 0; i < len; ++i) {
    crc ^= data[i];
    for (uint8_t b = 0; b < 8; ++b) {
      if (crc & 1) crc = (crc >> 1) ^ 0xA001;
      else crc >>= 1;
    }
  }
  return crc;
}

// Estructura de trama compacta
#pragma pack(push, 1)
struct FrameV1 {
  uint8_t  pre[2];           // 'A','G'
  uint8_t  ver;              // 0x01
  uint32_t dev;              // 32 bits del MAC (ID)
  uint32_t seq;              // contador
  int16_t  t_c_x100;         // temperatura *100 (°C)
  uint16_t rh_x100;          // humedad *100 (%)
  uint16_t press_hPa_x10;    // presión *10 (hPa)
  uint16_t soil_permille;    // humedad suelo en ‰ (0-1000)
  uint16_t vbat_mV;          // batería en mV
  uint16_t crc;              // CRC16-IBM sobre cabecera+datos
};
#pragma pack(pop)

// Lectura BME280 en modo de baja energía (forced)
bool read_bme280(float &tC, float &rh, float &p_hPa) {
  // Configurar I2C si no está iniciado
  static bool wireBegun = false;
  if (!wireBegun) {
    Wire.begin(I2C_SDA, I2C_SCL);
    wireBegun = true;
  }
  static bool bmeInit = false;
  if (!bmeInit) {
    // Probar 0x76 y 0x77
    if (!bme.begin(0x76) && !bme.begin(0x77)) {
      return false;
    }
    // Configuración mínima: oversampling x1, filtro off, modo FORCED
    bme.setSampling(
      Adafruit_BME280::MODE_FORCED,
      Adafruit_BME280::SAMPLE_X1,   // temp
      Adafruit_BME280::SAMPLE_X1,   // hum
      Adafruit_BME280::SAMPLE_X1,   // pres
      Adafruit_BME280::FILTER_OFF
    );
    bmeInit = true;
  }
  // Disparo de conversión en modo FORCED
  bme.takeForcedMeasurement();
  tC    = bme.readTemperature();          // °C
  rh    = bme.readHumidity();             // %
  p_hPa = bme.readPressure() / 100.0F;    // hPa
  return !(isnan(tC) || isnan(rh) || isnan(p_hPa));
}

// Inicialización ADS1115 en modo lectura single-shot
bool init_ads1115() {
  static bool init = false;
  if (!init) {
    if (!ads.begin(0x48)) return false;
    ads.setGain(GAIN_ONE); // ±4.096 V -> 0.125 mV/LSB
    init = true;
  }
  return true;
}

float read_ads_voltage(uint8_t ch) {
  int16_t counts = ads.readADC_SingleEnded(ch);
  return ads.computeVolts(counts); // Volts referidos a Vdd ADC
}

// Calibración simple para humedad de suelo (ajusta a tu sensor)
uint16_t soil_percent_permille_from_voltage(float v) {
  // Ajusta límites según calibración empírica de tu sonda
  // Ejemplo típico de sensor capacitivo: ~0.5 V seco, ~2.5 V muy húmedo
  const float v_dry = 0.5f;
  const float v_wet = 2.5f;
  float f = (v - v_dry) / (v_wet - v_dry);
  if (f < 0.0f) f = 0.0f;
  if (f > 1.0f) f = 1.0f;
  return (uint16_t)lroundf(f * 1000.0f); // ‰ (0–1000)
}

// ID de dispositivo a partir del MAC
uint32_t device_id32() {
  uint64_t mac = ESP.getEfuseMac();
  return (uint32_t)((mac >> 16) & 0xFFFFFFFF);
}

// Inicialización de la radio LoRa con parámetros de bajo consumo
bool lora_radio_begin(double freqMHz, int sf, int txPower) {
  // Apaga radios del SoC para ahorrar
  WiFi.mode(WIFI_OFF);
  btStop();

  SPI.begin(); // VSPI por defecto
  LoRa.setPins(LORA_SS, LORA_RST, LORA_DIO0);
  if (!LoRa.begin(freqMHz * 1e6)) {
    return false;
  }
  LoRa.setSPIFrequency(8E6);
  LoRa.setTxPower(txPower);          // dBm (máx. 20 con PA_BOOST, cumple normativa local)
  LoRa.setSpreadingFactor(sf);       // 7..12
  LoRa.setSignalBandwidth(125E3);    // 125 kHz (estándar)
  LoRa.setCodingRate4(5);            // 4/5
  LoRa.setPreambleLength(8);         // preámbulo estándar
  LoRa.enableCrc();                  // CRC de payload de la librería (además del nuestro)
  return true;
}

void lora_radio_sleep() {
  LoRa.idle();
  LoRa.sleep(); // SX1276 en sleep (consumo mínimo)
}

// Empaquetado y envío de trama binaria
bool send_frame_once(FrameV1 &frm) {
  // Calcula CRC sobre todo excepto el campo CRC
  frm.crc = 0;
  uint16_t crc = crc16_ibm(reinterpret_cast<const uint8_t*>(&frm), sizeof(frm) - sizeof(frm.crc));
  frm.crc = crc;

  // Emisión
  LoRa.beginPacket();
  LoRa.write(reinterpret_cast<const uint8_t*>(&frm), sizeof(frm));
  int err = LoRa.endPacket(true); // true = TX async (mejor para no bloquear)
  // Espera a que termine
  LoRa.idle();
  return (err == 1);
}

void setup() {
  Serial.begin(115200);
  delay(50);
  Serial.println();
  Serial.println(F("[lora-agro-telemetry-lowpower] Boot"));

  // Lógica de secuencia
  rtc_seq++;

  // I2C
  Wire.begin(I2C_SDA, I2C_SCL);
  if (!init_ads1115()) {
    Serial.println(F("ERR: ADS1115 no detectado en 0x48"));
  }

  // Sensores
  float tC=0, rh=0, p_hPa=0;
  bool bme_ok = read_bme280(tC, rh, p_hPa);
  if (!bme_ok) {
    Serial.println(F("ERR: BME280 no detectado (0x76/0x77)"));
  }

  // Lecturas ADS1115
  float v_soil = read_ads_voltage(SOIL_CH);
  float v_bat_div = read_ads_voltage(VBAT_CH); // miden VBAT/2 por divisor 100k/100k
  float vbat = v_bat_div * 2.0f; // Volts reales

  // Conversión a unidades compactas
  uint16_t soil_permille = soil_percent_permille_from_voltage(v_soil);
  int16_t t_c_x100 = (int16_t)lroundf(tC * 100.0f);
  uint16_t rh_x100 = (uint16_t)lroundf(rh * 100.0f);
  uint16_t press_hPa_x10 = (uint16_t)lroundf(p_hPa * 10.0f);
  uint16_t vbat_mV = (uint16_t)lroundf(vbat * 1000.0f);

  // Construcción de la trama
  FrameV1 frm{};
  frm.pre[0] = 'A'; frm.pre[1] = 'G';
  frm.ver = 0x01;
  frm.dev = device_id32();
  frm.seq = rtc_seq;
  frm.t_c_x100 = t_c_x100;
  frm.rh_x100 = rh_x100;
  frm.press_hPa_x10 = press_hPa_x10;
  frm.soil_permille = soil_permille;
  frm.vbat_mV = vbat_mV;

  Serial.printf("ID=0x%08lX SEQ=%lu T=%.2fC RH=%.2f%% P=%.1fhPa SOIL=%.1f%% VBAT=%.3fV\n",
    (unsigned long)frm.dev, (unsigned long)frm.seq,
    tC, rh, p_hPa, soil_permille/10.0f, vbat);

  // Radio
  if (!lora_radio_begin(LORA_FREQ_MHZ, LORA_SF, LORA_TX_PWR_DBM)) {
    Serial.println(F("ERR: Fallo init LoRa (frecuencia/pines/región)"));
  } else {
    bool ok = send_frame_once(frm);
    Serial.printf("TX %s, len=%u, CRC=0x%04X, F=%.1fMHz SF=%d PWR=%ddBm\n",
      ok ? "OK" : "ERR", (unsigned)sizeof(frm), frm.crc, LORA_FREQ_MHZ, LORA_SF, LORA_TX_PWR_DBM);
  }
  // Duerme la radio
  lora_radio_sleep();

  // Espera corta para drenar UART
  delay(50);

  // Programa deep sleep con temporizador
  esp_sleep_enable_timer_wakeup((uint64_t)WAKE_INTERVAL_S * 1000000ULL);
  Serial.printf("DeepSleep %us...\n", WAKE_INTERVAL_S);
  Serial.flush();
  esp_deep_sleep_start();
}

void loop() {
  // No se usa; el ESP32 nunca entra aquí debido a deep sleep
}

Puntos clave del código:
– Se desconectan WiFi y BT para reducir consumo.
– El BME280 se usa en modo forced, volviendo a sleep tras la medición.
– El ADS1115 realiza lecturas single-shot, que apagan internamente el conversor entre muestras.
– La trama “AG v1” incluye un CRC16-IBM propio y además se habilita el CRC de la librería LoRa.
– Se usa RTC_DATA_ATTR para mantener el contador de secuencia entre ciclos de deep sleep.
– El SX1276 se pone en sleep tras el envío.
– El ESP32 entra en deep sleep durante WAKE_INTERVAL_S (por defecto 900 s = 15 min).

Receptor de validación (opcional, misma TTGO LoRa32)

Para validar por aire, flashea en otra TTGO LoRa32 el siguiente receptor de propósito general. Solo decodifica cabecera y CRC para mostrar los valores.

#include <Arduino.h>
#include <SPI.h>
#include <LoRa.h>

static constexpr int LORA_SS = 18;
static constexpr int LORA_RST = 14;
static constexpr int LORA_DIO0 = 26;

#ifndef LORA_FREQ_MHZ
#define LORA_FREQ_MHZ 868.1
#endif
#ifndef LORA_SF
#define LORA_SF 7
#endif

#pragma pack(push,1)
struct FrameV1 {
  uint8_t  pre[2];
  uint8_t  ver;
  uint32_t dev;
  uint32_t seq;
  int16_t  t_c_x100;
  uint16_t rh_x100;
  uint16_t press_hPa_x10;
  uint16_t soil_permille;
  uint16_t vbat_mV;
  uint16_t crc;
};
#pragma pack(pop)

uint16_t crc16_ibm(const uint8_t* d, size_t n) {
  uint16_t crc=0xFFFF;
  for(size_t i=0;i<n;i++){ crc^=d[i]; for(uint8_t b=0;b<8;b++){ crc=(crc&1)?(crc>>1)^0xA001:(crc>>1);} }
  return crc;
}

void setup() {
  Serial.begin(115200);
  SPI.begin();
  LoRa.setPins(LORA_SS, LORA_RST, LORA_DIO0);
  if (!LoRa.begin(LORA_FREQ_MHZ*1e6)) {
    Serial.println("ERR: LoRa.begin()");
    while(1) delay(1000);
  }
  LoRa.setSpreadingFactor(LORA_SF);
  LoRa.setSignalBandwidth(125E3);
  LoRa.setCodingRate4(5);
  LoRa.enableCrc();
  Serial.println("RX listo");
}

void loop() {
  int packetSize = LoRa.parsePacket();
  if (packetSize == (int)sizeof(FrameV1)) {
    FrameV1 frm{};
    LoRa.readBytes((uint8_t*)&frm, sizeof(frm));
    uint16_t crc = frm.crc; frm.crc = 0;
    uint16_t calc = crc16_ibm((uint8_t*)&frm, sizeof(frm)-2);
    if (crc == calc && frm.pre[0]=='A' && frm.pre[1]=='G' && frm.ver==0x01) {
      float tC = frm.t_c_x100 / 100.0f;
      float rh = frm.rh_x100 / 100.0f;
      float p  = frm.press_hPa_x10 / 10.0f;
      float soilPct = frm.soil_permille / 10.0f;
      float vbat = frm.vbat_mV / 1000.0f;
      Serial.printf("OK dev=0x%08lX seq=%lu T=%.2fC RH=%.2f%% P=%.1fhPa SOIL=%.1f%% VBAT=%.3fV RSSI=%d SNR=%.1f\n",
        (unsigned long)frm.dev, (unsigned long)frm.seq, tC, rh, p, soilPct, vbat, LoRa.packetRssi(), LoRa.packetSnr());
    } else {
      Serial.println("ERR CRC/cabecera");
    }
  }
}

Compilación, flash y ejecución

A continuación los comandos exactos con PlatformIO CLI. Puedes usarlos en cualquier SO con la consola/terminal.

1) Verificar versiones de Python y PlatformIO:

python --version
pio --version

2) Crear el proyecto (en una carpeta nueva):

mkdir -p ~/proyectos/lora-agro-telemetry-lowpower
cd ~/proyectos/lora-agro-telemetry-lowpower
pio project init --board ttgo-lora32-v1

3) Sustituye platformio.ini por el proporcionado en este caso práctico y crea src/main.cpp con el código anterior.

4) Opcional: listar la placa para confirmar:

pio boards | grep -i ttgo

5) Actualizar paquetes y compilar:

pio pkg update
pio run -t clean
pio run

6) Conecta la TTGO LoRa32 por USB. Identifica el puerto:
– Windows: comprueba en el Administrador de dispositivos (COMx).
– Linux: dmesg | grep -i ttyUSB y ls -l /dev/ttyUSB*
– macOS: ls -l /dev/tty.SLAB_USBtoUART

7) Flashear el firmware:

pio run -t upload

8) Abrir monitor serie a 115200 baudios:

pio device monitor --baud 115200 --echo --filter time --filter esp32_exception_decoder

Notas específicas por SO:
– Linux udev: si no tienes permisos, crea /etc/udev/rules.d/99-usb-serial.rules con:
– SUBSYSTEM==»tty», ATTRS{idVendor}==»10c4″, ATTRS{idProduct}==»ea60″, MODE=»0666″, GROUP=»dialout»
– Luego: sudo udevadm control –reload-rules && sudo udevadm trigger
– Windows: si upload falla intermitente, prueba cables USB de calidad y puertos traseros.

Para el receptor opcional, crea otro proyecto (o un environment aparte) y repite los pasos con su main.cpp.

Validación paso a paso

1) Validación de consola del transmisor:
– Tras el reset, deberías ver líneas similares a:
– [lora-agro-telemetry-lowpower] Boot
– ID=0x12AB34CD SEQ=1 T=23.12C RH=45.67% P=1013.2hPa SOIL=56.7% VBAT=4.051V
– TX OK, len=21, CRC=0x5A93, F=868.1MHz SF=7 PWR=14dBm
– DeepSleep 900s…

  • SEQ aumentará en cada despertar (1,2,3…).

2) Validación de sensores:
– Si el BME280 no está o su dirección no coincide, verás ERR: BME280 no detectado.
– Si el ADS1115 no responde, verás ERR: ADS1115 no detectado en 0x48.
– Acerca un dedo al sensor de suelo (si está al aire) o insértalo en tierra húmeda; la lectura SOIL deberá aumentar.

3) Validación de radio con receptor (opcional, segunda TTGO LoRa32):
– En el receptor, al abrir el monitor serie deberías ver:
– RX listo
– OK dev=0x12AB34CD seq=1 T=23.12C RH=45.67% P=1013.2hPa SOIL=56.7% VBAT=4.051V RSSI=-72 SNR=9.8
– Comprueba que el dev (ID) y seq coinciden con el transmisor y que los valores son razonables.

4) Validación de consumo (orientativa):
– Con un medidor USB en línea, observa tres fases:
– Pico al transmitir LoRa (decenas de mA, típico 80–120 mA por milisegundos).
– Activo durante sensado y TX (20–70 mA según placa).
– Deep sleep: debería caer significativamente. En TTGO LoRa32, dependiendo del regulador y del SX1276, es habitual ver cientos de µA a ~1–2 mA. Registra el valor.
– Si el consumo en deep sleep no baja de ~5 mA, revisa:
– Que WiFi/BT estén apagados.
– Que no tengas LEDs siempre encendidos.
– Que el sensor de suelo no consuma de forma continua (puede alimentarse desde un GPIO + MOSFET para desconectarlo entre mediciones en una mejora posterior).

5) Validación de payload/CRC:
– Si alteras el código del receptor para forzar un CRC erróneo, debe reportar ERR CRC/cabecera, verificando que el emisor genera CRC correcto.

6) Verificación de duty-cycle:
– Para EU868, con WAKE_INTERVAL_S=900 (15 min), el duty-cycle es seguro. Si reduces el intervalo, asegúrate de cumplir normativa (1% por sub-banda típica).

Troubleshooting

1) No aparece el puerto serie:
– Instala el driver CP210x adecuado.
– Cambia de cable (evita “solo carga”).
– En Linux, añade usuario a dialout y reabre sesión.
– Prueba otro puerto USB (en PC de sobremesa, los traseros suelen ser más fiables).

2) Fallo al entrar en modo programación (upload):
– Mantén pulsado BOOT (GPIO0 a GND) mientras conectas USB, o pulsa RST manteniendo BOOT.
– En PlatformIO, intenta: pio run -t upload –upload-port /dev/ttyUSB0 (ajusta puerto).
– Desconecta otros dispositivos serie que “secuestren” el puerto.

3) LoRa.begin() falla:
– Verifica antena conectada y banda correcta (LORA_FREQ_MHZ).
– Confirma pines del SX1276 (para TTGO: SS=18, RST=14, DIO0=26). Si usas otro modelo, ajusta.
– No mezcles región (EU868 vs US915) con frecuencias no válidas.

4) No se observan lecturas I2C:
– Confirmar 3V3 y GND a los módulos.
– Verifica que BME280 no sea en realidad BMP280 (sin humedad).
– Comprueba direcciones: muchos BME280 vienen a 0x76; si no responde, prueba 0x77 en el código (el ejemplo ya intenta ambas).
– Cableado SDA/SCL correcto (21/22) y longitud/ruido razonables.

5) ADS1115 saturado o lecturas erróneas:
– Revisa GAIN. Con GAIN_ONE (±4.096 V) y VDD de 3.3 V es adecuado; si la señal es baja, podrías usar GAIN_TWO para mejor resolución.
– Asegura masas comunes y que el sensor de suelo se alimenta a 3.3 V (no 5 V).

6) Deep sleep no reduce consumo:
– Asegura que LoRa.sleep() se llama antes de esp_deep_sleep_start().
– Verifica que no haya periferia externa alimentada sin necesidad (sensores).
– Revisa que WiFi.mode(WIFI_OFF) y btStop() se ejecuten sin errores.

7) Mensajes esporádicos “Brownout detector”:
– Batería descargada o picos de corriente durante TX.
– Usa una LiPo en buen estado y un cable corto; considera un condensador adicional de desacoplo (p. ej. 470 µF cerca del módulo LoRa).

8) Cobertura de radio insuficiente:
– Aumenta SF (ej., LORA_SF=9 o 10) a costa de tiempo en aire.
– Mejora antena, eleva el nodo, evita obstáculos.
– Ajusta potencia respetando límites regulatorios (14 dBm EU, 20 dBm en ciertos casos con restricciones).

Mejoras/variantes

  • Cortar alimentación al sensor de suelo:
  • Controla VCC del sensor con un MOSFET P o un switch de alta/low-side desde un GPIO para apagarlo entre mediciones.
  • Encriptación ligera:
  • Añade cifrado (p. ej., AES-128 CTR) al payload antes de LoRa.write(); intercambia una clave en el receptor.
  • Confirmación y reintentos:
  • Implementa ACK ligero con el receptor enviando un paquete corto de vuelta; reintenta si no hay ACK, con contador limitado para no violar duty-cycle.
  • LoRaWAN (si tu caso requiere):
  • Sustituye LoRa “crudo” por LMIC (MCCI) y gestiona altas en un Network Server (TTN/ChirpStack). Implica más complejidad y consumo.
  • ADR y autoajuste:
  • Cambia SF en función del RSSI/SNR reportado por el receptor a lo largo del tiempo.
  • Medición adicional:
  • Usa ADS1115 canales A2/A3 para sensores de pH/EC aislados o tensiómetros, con cuidado de masas y filtrado.
  • Optimización de tiempo en aire:
  • Compacta más el payload (por ejemplo, delta-encoding y escala fija) y reduce la cadencia en condiciones estables.

Checklist de verificación

  • [ ] He instalado PlatformIO Core 6.1.14 y Python 3.11.6; pio –version responde correctamente.
  • [ ] El driver CP210x está instalado y el puerto serie aparece al conectar la TTGO LoRa32.
  • [ ] He creado el proyecto con board=ttgo-lora32-v1 y he pegado platformio.ini con versiones y build_flags indicados.
  • [ ] He cableado BME280 y ADS1115 a SDA=21 y SCL=22, con 3V3 y GND compartidos.
  • [ ] El sensor de suelo está conectado a A0 del ADS1115; el divisor VBAT (100k/100k) a A1.
  • [ ] El transmisor compila, flashea y muestra por serie la línea de mediciones y “TX OK”.
  • [ ] Puedo ver el incremento de SEQ en cada despertar y cambios en SOIL al humedecer/secar la sonda.
  • [ ] En validación por aire (opcional), el receptor TTGO LoRa32 decodifica tramas “AG v1” con CRC OK.
  • [ ] El consumo cae notablemente en deep sleep tras unos segundos (verificado con medidor).
  • [ ] He ajustado LORA_FREQ_MHZ/SF/PA según mi región y cobertura requerida.

Con este caso práctico tendrás un nodo agrícola de telemetría LoRa de bajo consumo funcionando con el modelo exacto TTGO LoRa32 (ESP32 + SX1276) y sensores BME280 + ADS1115, listo para desplegar en campo y extender con mejoras avanzadas.

Encuentra este producto y/o libros sobre este tema en Amazon

Ir a Amazon

Como afiliado de Amazon, gano con las compras que cumplan los requisitos. Si compras a través de este enlace, ayudas a mantener este proyecto.

Quiz rápido

Pregunta 1: ¿Qué versión de Windows es necesaria para instalar drivers según el artículo?




Pregunta 2: ¿Cuál es la herramienta de línea de comandos mencionada en el artículo?




Pregunta 3: ¿Qué versión de Python se requiere para el proyecto?




Pregunta 4: ¿Qué driver es necesario instalar para la TTGO LoRa32 en Windows/macOS?




Pregunta 5: ¿Qué librería se utiliza para el sensor BME280?




Pregunta 6: ¿Qué dirección I2C se utiliza por defecto para el módulo ADS1115?




Pregunta 7: ¿Cuál es la banda de frecuencia configurada por defecto en el caso práctico?




Pregunta 8: ¿Qué comando se debe ejecutar para añadir un usuario al grupo 'dialout' en Linux?




Pregunta 9: ¿Qué herramienta se suministra por PlatformIO para flashear el ESP32?




Pregunta 10: ¿Qué tipo de sensor se menciona específicamente para medir la humedad del suelo?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Ingeniero Superior en Electrónica de Telecomunicaciones e Ingeniero en Informática (titulaciones oficiales en España).

Sígueme:


Caso práctico: Beamforming y DoA en ESP32-S3 e INMP441 (I2S)

Caso práctico: Beamforming y DoA en ESP32-S3 e INMP441 (I2S) — hero

Objetivo y caso de uso

Qué construirás: Un sistema avanzado de localización acústica utilizando el ESP32-S3-DevKitC-1 y el micrófono INMP441 para capturar audio estéreo y estimar el tiempo de llegada diferido (TDOA).

Para qué sirve

  • Localización de fuentes de sonido en entornos ruidosos utilizando técnicas de beamforming.
  • Detección de la dirección de llegada (DoA) de sonidos en aplicaciones de robótica y automatización.
  • Implementación de sistemas de seguridad acústica que responden a sonidos específicos.
  • Desarrollo de asistentes de voz que mejoran la precisión en la detección de comandos.

Resultado esperado

  • Precisión en la localización de fuentes de sonido con un margen de error de menos de 5 grados.
  • Latencia de procesamiento de audio de menos de 50 ms para respuestas en tiempo real.
  • Capacidad de manejar hasta 10 paquetes de audio por segundo para análisis en tiempo real.
  • Consumo de energía optimizado para funcionamiento continuo en dispositivos portátiles.

Público objetivo: Desarrolladores y entusiastas de la electrónica; Nivel: Avanzado

Arquitectura/flujo: Captura de audio estéreo mediante INMP441, procesamiento en ESP32-S3, estimación de TDOA y DoA, salida de datos a través de MQTT.

Nivel: Avanzado

Prerrequisitos

Sistema operativo y herramientas probadas

  • Windows 11 23H2 (x64) con PowerShell 7.4
  • Ubuntu 22.04 LTS (x86_64) con Bash 5.1
  • macOS 13 Ventura (Apple Silicon o Intel) con Terminal zsh 5.8

Toolchain exacta y versiones

  • PlatformIO Core 6.1.14 (instalado vía pip)
  • Especificaciones de proyecto PlatformIO:
  • platform = espressif32@6.5.0
  • framework = espidf (ESP-IDF 5.1.2)
  • board = esp32-s3-devkitc-1
  • platform packages fijados:
    • platformio/toolchain-xtensa-esp32s3@12.2.0+20230208
    • platformio/framework-espidf@5.1.2
    • platformio/tool-esptoolpy@1.40501.0 (esptool.py 4.5.1)
  • Python 3.10.x (recomendado 3.10.12) para PlatformIO
  • Drivers USB:
  • CP210x USB to UART Bridge (si tu placa expone puerto UART vía CP2102N). En Windows, instalar “CP210x Universal Windows Driver v10.1.10” desde Silicon Labs si el Puerto COM no aparece automáticamente.
  • En ESP32-S3-DevKitC-1 puedes usar también el puerto USB nativo (GPIO19/20); no requiere driver adicional en macOS/Linux, y en Windows usa WinUSB.

Notas:
– Las versiones arriba listadas son las que se usarán en los comandos y en platformio.ini para reproducibilidad. Si ya tienes PlatformIO en VS Code, asegúrate de que el Core sea 6.1.14 o usa los comandos CLI indicados.

Materiales

  • ESP32-S3-DevKitC-1 (modelo exacto de la placa)
  • 2x INMP441 I2S mics (micrófonos digitales I2S, cada uno con pines: VDD, GND, SCK, WS, SD, L/R)
  • Separador rígido para los micrófonos (barra, regla o impreso 3D) con distancia conocida d entre cápsulas, p. ej. 6.0 cm
  • Cables Dupont macho-hembra (mínimo 10)
  • Cable USB-C (para la ESP32-S3-DevKitC-1)
  • Ordenador con uno de los SO soportados
  • Fuente: Velcro/cinta doble cara para fijar los mics a la barra
  • Opcional:
  • Sonda lógica o analizador para verificar I2S
  • Altavoz activo portátil para pruebas de dirección
  • Trípode o base para mantener el arreglo en un plano

Objetivo del proyecto: i2s-beamforming-direction-finding con 2 micrófonos INMP441 y una ESP32-S3-DevKitC-1.

Preparación y conexión

Consideraciones de hardware

  • El INMP441 es un micrófono digital I2S con salida de 24 bits (justificada a 32 bits en el bus).
  • Ambos INMP441 comparten reloj de bit (BCLK, SCK), reloj de palabra (LRCLK/WS) y la línea de datos (SD). Cada micrófono solo conduce la línea SD en su canal (L o R) según el pin L/R:
  • L/R = GND → canal Izquierdo (L)
  • L/R = VDD → canal Derecho (R)
  • Alimentación a 3.3 V. No usar 5 V.

Usaremos un único bus I2S en modo estéreo. El ESP32-S3 lee dos canales simultáneamente (L y R) a 48 kHz.

Mapeo de pines recomendado (ESP32-S3-DevKitC-1)

  • Seleccionamos GPIO que no interfieran con el USB nativo ni con señales de arranque. Los siguientes son seguros en la mayoría de revisiones de la DevKitC-1:
Señal ESP32-S3-DevKitC-1 GPIO INMP441 (ambos mics)
BCLK / SCK GPIO12 SCK de Mic1 y Mic2 (común)
LRCLK / WS GPIO13 WS de Mic1 y Mic2 (común)
DIN (I2S RX) GPIO14 SD de Mic1 y Mic2 (compartida)
VDD 3V3 VDD de Mic1 y Mic2
GND GND GND de Mic1 y Mic2
L/R Mic1 GND (canal L)
L/R Mic2 3V3 (canal R)

Notas:
– Ambos micrófonos comparten SD hacia la entrada DIN del ESP32-S3; no hay conflicto porque cada uno habla solo en su semiperiodo (L o R).
– Si tu placa INMP441 tiene pads de soldadura para L/R, asegúrate de puentear correctamente uno a GND y el otro a 3V3.

Geometría del arreglo

  • Distancia entre centros acústicos de los micrófonos, d: 6.0 cm (0.06 m). Mide y anota con precisión (±0.5 mm). Esta distancia se usará en las ecuaciones.
  • Arreglo lineal (dos micrófonos en línea recta). Ángulo de llegada (azimut) θ definido respecto al eje normal central, con θ ∈ [−90°, +90°].

Código completo (ESP-IDF con PlatformIO)

El ejemplo está escrito en C usando el driver I2S v2 de ESP-IDF 5.1 y cálculo de TDOA (Time Difference of Arrival) por correlación cruzada en el dominio temporal en una ventana deslizante. A partir de la TDOA se estima el ángulo: θ = arcsin(c·τ/d), donde c ≈ 343 m/s (20 °C), τ es el retardo entre canales y d la separación.

Características:
– Frecuencia de muestreo: 48 kHz
– Profundidad de palabra en bus: 32 bits (los INMP441 entregan 24 bits justificados)
– Ventana de procesamiento: N = 1024 frames (≈21.33 ms)
– Búsqueda de retraso: ±16 muestras (cubre ±9 muestas necesarias para d=6 cm a 48 kHz)
– Normalización previa (quita DC y escala por desviación típica)
– Interpolación parabólica para submuestra

Estructura:
– Inicialización I2S en modo estándar (Philips), RX maestro
– Tarea principal que:
1) Lee bloques del bus I2S
2) Demultiplexa L/R y convierte a float normalizada
3) Calcula TDOA mediante correlación
4) Estima θ y lo imprime por puerto serie

platformio.ini

; platformio.ini — Toolchain y build reproducibles
[env:esp32-s3-devkitc-1]
platform = espressif32@6.5.0
framework = espidf
board = esp32-s3-devkitc-1

; Paquetes fijados (versiones exactas)
platform_packages =
  platformio/toolchain-xtensa-esp32s3@12.2.0+20230208
  platformio/framework-espidf@5.1.2
  platformio/tool-esptoolpy@1.40501.0

; Opciones de build/flash/monitor
monitor_speed = 115200
board_build.flash_mode = qio
build_unflags = -Os
build_flags =
  -O2
  -DLOG_LOCAL_LEVEL=ESP_LOG_INFO

src/main.c

#include <stdio.h>
#include <string.h>
#include <math.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_err.h"
#include "driver/i2s_std.h"
#include "driver/gpio.h"

static const char *TAG = "BF_DF";

// Parámetros físicos
#define SPEED_OF_SOUND      343.0f      // m/s a 20°C
#define MIC_DISTANCE_M      0.06f       // 6 cm entre micrófonos

// Pines I2S (ESP32-S3-DevKitC-1)
#define I2S_BCLK_IO         12
#define I2S_WS_IO           13
#define I2S_SD_IO           14

// I2S y DSP
#define SAMPLE_RATE         48000
#define WORD_BITS           I2S_DATA_BIT_WIDTH_32BIT
#define SLOT_MODE           I2S_SLOT_MODE_STEREO

// Procesamiento
#define FRAME_LEN           1024        // frames estéreo por iteración
#define MAX_LAG_SAMPLES     16          // búsqueda de retardo ±16
#define READ_TIMEOUT_MS     1000

// Buffer I2S: 2 canales * FRAME_LEN * 4 bytes (32 bits)
static int32_t i2s_rx_buf[FRAME_LEN * 2];

// Señales normalizadas
static float xL[FRAME_LEN];
static float xR[FRAME_LEN];

// Canal I2S
static i2s_chan_handle_t rx_chan = NULL;

static esp_err_t i2s_init_std(void)
{
    esp_err_t ret;
    i2s_chan_config_t chan_cfg = {
        .id = I2S_NUM_0,
        .role = I2S_ROLE_MASTER,
        .dma_desc_num = 8,
        .dma_frame_num = 256,
        .auto_clear = true,
        .intr_priority = 0
    };

    ret = i2s_new_channel(&chan_cfg, NULL, &rx_chan);
    if (ret != ESP_OK) return ret;

    i2s_std_config_t std_cfg = {
        .clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(SAMPLE_RATE),
        .slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(WORD_BITS, SLOT_MODE),
        .gpio_cfg = {
            .mclk = I2S_GPIO_UNUSED,
            .bclk = I2S_BCLK_IO,
            .ws   = I2S_WS_IO,
            .dout = I2S_GPIO_UNUSED,
            .din  = I2S_SD_IO,
            .invert_flags = {
                .mclk_inv = false,
                .bclk_inv = false,
                .ws_inv = false,
            }
        }
    };

    ret = i2s_channel_init_std_mode(rx_chan, &std_cfg);
    if (ret != ESP_OK) return ret;

    ret = i2s_channel_enable(rx_chan);
    return ret;
}

static inline float s32_to_float_norm(int32_t s)
{
    // INMP441: 24 bits justificados en 32; mover 8 bits y normalizar a [-1, 1)
    int32_t v24 = (s >> 8);
    return (float)v24 / 8388608.0f; // 2^23
}

static void preprocess_normalize(const int32_t *src, float *dstL, float *dstR, size_t n)
{
    double meanL = 0.0, meanR = 0.0;
    for (size_t i = 0; i < n; ++i) {
        float l = s32_to_float_norm(src[2*i + 0]);
        float r = s32_to_float_norm(src[2*i + 1]);
        dstL[i] = l;
        dstR[i] = r;
        meanL += l;
        meanR += r;
    }
    meanL /= (double)n;
    meanR /= (double)n;

    double varL = 0.0, varR = 0.0;
    for (size_t i = 0; i < n; ++i) {
        float l = dstL[i] - (float)meanL;
        float r = dstR[i] - (float)meanR;
        dstL[i] = l;
        dstR[i] = r;
        varL += (double)l * (double)l;
        varR += (double)r * (double)r;
    }
    varL /= (double)(n - 1);
    varR /= (double)(n - 1);

    float stdL = (float)sqrt(varL + 1e-12);
    float stdR = (float)sqrt(varR + 1e-12);

    for (size_t i = 0; i < n; ++i) {
        dstL[i] /= stdL;
        dstR[i] /= stdR;
    }
}

// Correlación cruzada restringida a ±MAX_LAG_SAMPLES
static int est_tdoa_lag(const float *x, const float *y, size_t n, int maxLag, float *peakCorr, float *lagFrac)
{
    // Cálculo NCC (Normalized Cross-Correlation) local
    float bestVal = -1e9f;
    int bestLag = 0;

    for (int lag = -maxLag; lag <= maxLag; ++lag) {
        double acc = 0.0;
        size_t count = 0;
        if (lag >= 0) {
            for (size_t i = 0; i + lag < n; ++i) {
                acc += (double)x[i + lag] * (double)y[i];
                count++;
            }
        } else { // lag < 0
            int k = -lag;
            for (size_t i = 0; i + k < n; ++i) {
                acc += (double)x[i] * (double)y[i + k];
                count++;
            }
        }
        float c = (count > 0) ? (float)(acc / (double)count) : 0.0f;
        if (c > bestVal) {
            bestVal = c;
            bestLag = lag;
        }
    }

    // Interpolación parabólica submuestral: usa valores en bestLag-1, bestLag, bestLag+1
    // c(l) ~ a(l - l0)^2 + b
    float c_m1 = -1e6f, c_0 = -1e6f, c_p1 = -1e6f;

    // Calcular c_m1
    {
        double acc = 0.0;
        size_t count = 0;
        int lag = bestLag - 1;
        if (lag >= -maxLag) {
            if (lag >= 0) {
                for (size_t i = 0; i + lag < n; ++i) { acc += (double)x[i + lag] * (double)y[i]; count++; }
            } else {
                int k = -lag;
                for (size_t i = 0; i + k < n; ++i) { acc += (double)x[i] * (double)y[i + k]; count++; }
            }
            c_m1 = (count > 0) ? (float)(acc / (double)count) : -1e6f;
        }
    }
    // Calcular c_0
    c_0 = bestVal;
    // Calcular c_p1
    {
        double acc = 0.0;
        size_t count = 0;
        int lag = bestLag + 1;
        if (lag <= maxLag) {
            if (lag >= 0) {
                for (size_t i = 0; i + lag < n; ++i) { acc += (double)x[i + lag] * (double)y[i]; count++; }
            } else {
                int k = -lag;
                for (size_t i = 0; i + k < n; ++i) { acc += (double)x[i] * (double)y[i + k]; count++; }
            }
            c_p1 = (count > 0) ? (float)(acc / (double)count) : -1e6f;
        }
    }

    float frac = 0.0f; // offset fraccional ∈ [-0.5, 0.5] aprox.
    float denom = (c_m1 - 2.0f*c_0 + c_p1);
    if (fabsf(denom) > 1e-9f) {
        frac = 0.5f * (c_m1 - c_p1) / denom;
        if (frac > 0.5f) frac = 0.5f;
        if (frac < -0.5f) frac = -0.5f;
    }

    if (peakCorr) *peakCorr = bestVal;
    if (lagFrac) *lagFrac = frac;
    return bestLag;
}

static float clampf(float v, float lo, float hi)
{
    if (v < lo) return lo;
    if (v > hi) return hi;
    return v;
}

void app_main(void)
{
    ESP_LOGI(TAG, "Inicializando I2S...");
    ESP_ERROR_CHECK(i2s_init_std());

    ESP_LOGI(TAG, "Iniciando captura a %d Hz, FRAME_LEN=%d", SAMPLE_RATE, FRAME_LEN);

    while (1) {
        size_t to_read = sizeof(i2s_rx_buf);
        size_t bytes_read = 0;
        esp_err_t err = i2s_channel_read(rx_chan, i2s_rx_buf, to_read, &bytes_read, pdMS_TO_TICKS(READ_TIMEOUT_MS));
        if (err != ESP_OK || bytes_read != to_read) {
            ESP_LOGW(TAG, "Lectura I2S incompleta: err=%d, bytes=%u", err, (unsigned)bytes_read);
            continue;
        }

        // Preprocesado: DC removal + normalización de energía
        preprocess_normalize(i2s_rx_buf, xL, xR, FRAME_LEN);

        // Estimar TDOA (lag y)
        float peakCorr = 0.0f, frac = 0.0f;
        int lag = est_tdoa_lag(xL, xR, FRAME_LEN, MAX_LAG_SAMPLES, &peakCorr, &frac);
        float lag_total = (float)lag + frac; // en muestras (submuestra)
        float tau = lag_total / (float)SAMPLE_RATE; // segundos

        // Ángulo de llegada (DoA) en grados
        float argument = clampf((SPEED_OF_SOUND * tau) / MIC_DISTANCE_M, -1.0f, 1.0f);
        float theta_rad = asinf(argument);
        float theta_deg = theta_rad * 180.0f / (float)M_PI;

        // Métrica de confianza simple (pico de correlación)
        // Nota: valores de peakCorr cercanos a 1 implican fuerte correlación
        float conf = clampf((peakCorr + 1.0f) / 2.0f, 0.0f, 1.0f);

        // Salida por serie
        printf("{\"deg\":%.2f,\"lag\":%.3f,\"tau_ms\":%.3f,\"peak\":%.3f}\n",
               theta_deg, lag_total, tau * 1000.0f, peakCorr);

        // Reporte legible
        ESP_LOGI(TAG, "DoA: %7.2f deg | lag: %+6.3f samp | tau: %+7.3f ms | peak: %.3f conf: %.2f",
                 theta_deg, lag_total, tau * 1000.0f, peakCorr, conf);

        // Pequeña pausa (opcional)
        vTaskDelay(pdMS_TO_TICKS(5));
    }
}

Breve explicación de partes clave:
– i2s_init_std: configura I2S en modo estándar (Philips), maestro, estéreo 32-bit por slot, 48 kHz. Se asignan pines GPIO12/13/14 a BCLK/WS/DIN respectivamente.
– preprocess_normalize: convierte 32→24 bits, quita DC (media) y normaliza por desviación típica para aproximar un pre-blanqueo; mejora la robustez de la correlación frente a señales con energía desigual.
– est_tdoa_lag: calcula la correlación cruzada de xL y xR en una ventana y busca el lag de máxima coincidencia en ±16 muestras. Se interpola con una parábola local para obtener submuestra.
– Cálculo de θ: arcsin(c·τ/d), con saturación a [-1,1] para robustez numérica. Se imprime JSON por stdout para facilitar parsing, además de un log humano.
– FRAME_LEN y MAX_LAG_SAMPLES se ajustan a la geometría y Fs. Con d=0.06 m y Fs=48 kHz, el máximo retraso absoluto es ~8.4 muestras; ±16 aporta margen.

Compilación/flash/ejecución

Instalación de PlatformIO Core 6.1.14

  • Windows/macOS/Linux:
  • Requiere Python 3.10.x disponible como python3 en PATH.

Comandos:

# 1) Crear y activar un entorno (opcional pero recomendado)
python3 -m venv .venv
source .venv/bin/activate   # en Windows: .venv\Scripts\activate

# 2) Instalar PlatformIO Core versión exacta
python3 -m pip install --upgrade pip
python3 -m pip install platformio==6.1.14

# 3) Verificar versión
pio --version
# Debe mostrar: PlatformIO Core, version 6.1.14

Inicializar proyecto y aplicar configuración

# 4) Crear carpeta del proyecto
mkdir esp32s3_i2s_beamforming && cd esp32s3_i2s_beamforming

# 5) Inicializar proyecto para ESP32-S3-DevKitC-1
pio project init --board esp32-s3-devkitc-1

# 6) Sobrescribir platformio.ini con el contenido del bloque anterior
#    (edita con tu editor y pega el contenido exacto)

# 7) Crear carpeta src y archivo main.c (pega el código del bloque anterior)
mkdir -p src
$EDITOR src/main.c

Compilar, flashear y monitorizar

  • Conecta la ESP32-S3-DevKitC-1 por USB-C.
  • Identifica el puerto serie:
  • Windows: Revisar “Puertos (COM y LPT)” en el Administrador de dispositivos (p. ej., COM7).
  • macOS: ls /dev/tty.usb* (p. ej., /dev/tty.usbmodemXXXXXXXX)
  • Linux: dmesg | tail o ls /dev/ttyACM /dev/ttyUSB.

Comandos:

# 8) Compilación
pio run

# 9) Flasheo (si tu puerto no se detecta automáticamente, especifica --upload-port)
pio run --target upload --upload-port <PUERTO_SERIAL>

# 10) Monitor serie a 115200 baudios
pio device monitor -b 115200

Notas:
– Si usas el USB nativo CDC del S3, puede presentarse como /dev/ttyACM0 (Linux) o tty.usbmodem (macOS).
– Para Windows, si no aparece el COM, instala el CP210x driver.

Validación paso a paso

Objetivo: Confirmar que el sistema estima correctamente la dirección (DoA) de una fuente sonora en el plano del arreglo.

1) Comprobación eléctrica
– Verifica continuidad de:
– BCLK (SCK) común a ambos INMP441 y GPIO12
– LRCLK (WS) común y GPIO13
– SD común y GPIO14
– VDD 3V3 a ambos mics
– GND común entre placa y mics
– Verifica L/R:
– Mic1 L/R a GND (Left)
– Mic2 L/R a 3V3 (Right)

2) Salida básica por consola
– Abre el monitor serie.
– Debes ver líneas como:
– {«deg»:-5.40,»lag»:-0.700,»tau_ms»:-0.015,»peak»:0.312}
– I (BF_DF) DoA: -5.40 deg | lag: -0.700 samp | tau: -0.015 ms | peak: 0.312 conf: 0.66
– Si peak ~ 0 y deg errático, revisa conexiones/ruido amb.

3) Prueba con ruido impulsivo (aplausos)
– Coloca una fuente (tus palmas) a ~0° (frente al centro del arreglo) a 0.5–1 m.
– Esperado: θ cercano a 0°, fluctuando ±5–10°.
– Mueve la fuente a la izquierda (lado del Mic1/L):
– Esperado: θ negativo (por convención usada), p. ej., -30° a -60°.
– Mueve la fuente a la derecha (lado del Mic2/R):
– Esperado: θ positivo, p. ej., +30° a +60°.

4) Prueba con tono continuo
– Usa un altavoz con tono 1 kHz a volumen moderado.
– Colócalo a ~30° respecto al eje. Mantén la distancia y evita reflexiones.
– Esperado: θ estable ±5° con peakCorr más alto (0.4–0.8). Si peak < 0.2, aumenta SNR o reduce reverberación.

5) Verificación de cotas físicas
– Para d=0.06 m y Fs=48000 Hz, TDOA máximo teórico = d/c ≈ 0.000175 s → ~8.4 muestras.
– Observa lag entre ~-8.5 y +8.5. Lag fuera de ese rango indica cableado L/R invertido o error de parámetros.

6) Calibración de distancia
– Si nota sesgo sistemático (p. ej., siempre subestima el ángulo), mide d con más precisión y ajusta MIC_DISTANCE_M en el código.

7) Comprobación de estabilidad
– Observa conf (mapeada a partir de peakCorr). Debe aumentar con señales directas y disminuir con ruido difuso o reverberación.

8) Persistencia de resultados
– Captura 30 s de logs. Exporta a CSV/JSON para graficar θ(t) y evaluar varianza en distintas posiciones.

Troubleshooting

1) No aparece puerto serie
– Solución:
– Cambia de cable USB-C (algunos solo suministran alimentación).
– Instala driver CP210x en Windows.
– Prueba el puerto USB nativo (CDC) de la S3 si tu DevKit lo expone.
– En Linux, agrega tu usuario al grupo dialout: sudo usermod -a -G dialout $USER y reinicia sesión.

2) Lecturas I2S vacías o erráticas (bytes_read < to_read)
– Causas probables:
– Pines BCLK/WS/DIN erróneos.
– SD no compartida correctamente entre los dos micrófonos.
– Timeout por bloqueos en ISR o CPU saturada.
– Soluciones:
– Verifica el cableado y continuidad.
– Reduce temporalmente FRAME_LEN a 512 para probar.
– Asegura que READ_TIMEOUT_MS sea suficiente (>= 1000 ms ya lo es).

3) Canales invertidos o ángulos con signo opuesto
– Causa: L/R mal conectados (ambos a GND o ambos a 3V3).
– Solución:
– Mic1 L/R a GND (L), Mic2 L/R a 3V3 (R).
– Si no puedes re-cablear, intercambia xL y xR en el código.

4) Ángulos saturados cerca de ±90° aun con fuente frontal
– Causas:
– d mal medido (demasiado grande en el código).
– Reverberación fuerte creando falsas correlaciones.
– Soluciones:
– Mide d y ajusta MIC_DISTANCE_M.
– Prueba en espacio menos reverberante; acerca la fuente.

5) Distorsión o clipping en señales
– Causas:
– Sonido excesivamente fuerte en campo cercano.
– Conversión 24→float mal escalada (shift inadecuado).
– Soluciones:
– Aleja la fuente o reduce volumen.
– Ajusta s32_to_float_norm: usa división por 2^23 (8388608.0f), como está en el código.

6) CPU Load alto o watchdog reset
– Causas:
– FRAME_LEN grande combinado con logs intensivos.
– Soluciones:
– Baja la verbosidad (LOG_LOCAL_LEVEL).
– Reduce frecuencia de logs (imprimir 1 de cada 5 iteraciones).
– Ajusta -O2/-O3 en build_flags.

7) “lag” fuera de ±16 con picos falsos
– Causas:
– Señales altamente periódicas (tonos puros) con ambigüedad de fase.
– Soluciones:
– Usa ruido de banda ancha o voz/música.
– Aumenta MAX_LAG_SAMPLES a 24–32 (con coste de CPU) o añade una heurística de banda ancha (filtro pasabanda 300–3400 Hz).

8) Ningún pico (peakCorr ≈ 0)
– Causas:
– SD desconectado, WS/BCLK ausentes.
– Micrófonos defectuosos.
– Soluciones:
– Verifica con analizador lógico que BCLK y WS están presentes (BCLK ≈ 3.072 MHz a 48 kHz con 32 bits*2 canales).
– Cambia micrófono para descartar fallo.

Mejoras/variantes

  • GCC-PHAT en frecuencia:
  • Sustituir la correlación temporal por GCC-PHAT (FFT → X1·conj(X2)/|X1·conj(X2)| → IFFT → argmax), mejora robustez a reverberación. Puedes integrar kissFFT o esp-dsp (FFT en ESP-IDF).
  • Beamforming delay-and-sum:
  • Añade un barrido de ángulos θ ∈ [−90°, +90°] y aplica retardos fraccionales (interpolación lineal o Lagrange orden 3) para alinear señales y maximizar energía sumada. El ángulo con máxima SNR define DoA.
  • Estimación submuestral avanzada:
  • Usa ajuste por correlación generalizada con ventanas (Hann/Blackman) y parabolic peak fitting mejorado (Quinn-Fernandes).
  • Calibración de ganancia y fase:
  • Compensa diferencias de sensibilidad entre mics aplicando escalado por RMS y compensación de fase/miniretardos medidos con tono de referencia.
  • Aumento de Fs y downsampling:
  • Captura a 96 kHz y haz decimación por 2 con filtro FIR; incrementa la resolución temporal de TDOA antes de decimar.
  • Filtrado adaptativo:
  • Pasabanda 300–3400 Hz para voz o 200–8000 Hz para música; mejora correlación y reduce baja frecuencia (ruido HVAC).
  • Extensión a N>2 micrófonos:
  • Con ESP32-S3 e I2S-TDM (módulos I2S MEMS TDM) puedes formar arreglos lineales/circulares para 2D-DoA y beamforming con mayor directividad.

Checklist de verificación

  • [ ] He instalado PlatformIO Core 6.1.14 con Python 3.10.x.
  • [ ] He creado el proyecto con board=esp32-s3-devkitc-1 y platform=espressif32@6.5.0.
  • [ ] He copiado el platformio.ini con los platform_packages exactos (toolchain-xtensa-esp32s3 12.2.0+20230208, framework-espidf 5.1.2, esptoolpy 4.5.1).
  • [ ] He cableado los dos INMP441 con BCLK→GPIO12, WS→GPIO13, SD→GPIO14, VDD→3V3, GND→GND.
  • [ ] L/R de Mic1 a GND (Left), L/R de Mic2 a 3V3 (Right).
  • [ ] He fijado la distancia d entre micrófonos (p. ej., 6.0 cm) y la he puesto en MIC_DISTANCE_M.
  • [ ] El proyecto compila (pio run) sin errores.
  • [ ] El flasheo (pio run –target upload) se completa y el monitor serie muestra registros a 115200 bps.
  • [ ] Al aplaudir frente a la pareja de micrófonos, la estimación de ángulo θ está cerca de 0°.
  • [ ] Al mover la fuente a izquierda/derecha, θ cambia de signo y magnitud de manera coherente.
  • [ ] peakCorr > 0.2 cuando la fuente está clara y cerca; si no, ajusto el entorno/volumen.

Apéndice: Notas prácticas y recomendaciones

  • Longitud de cables: Mantén los cables I2S cortos y paralelos. Evita puentes largos y flojos; el SD especialmente debe ser limpio.
  • Alimentación limpia: Usa el 3V3 de la DevKitC-1. Si usas protoboard, revisa falsos contactos.
  • Protección ESD: Evita tocar los diafragmas de los micrófonos. Manipula por los bordes del PCB.
  • Organización de logs: El formato JSON impreso permite que captures datos con pio device monitor –raw > logs.jsonl y luego los analices con Python.
  • Temperatura y c: La velocidad del sonido depende de la temperatura. Para precisión, usa c ≈ 331 + 0.6·T(°C). Ajusta SPEED_OF_SOUND si trabajas lejos de 20 °C.
  • Saturación por reverberación: En interiores con paredes cercanas, prueba con ventanas más cortas (FRAME_LEN=512) o filtrado pasabanda.

Con estos pasos y el código proporcionado, tendrás un sistema completo de i2s-beamforming-direction-finding con el hardware exacto ESP32-S3-DevKitC-1 + 2x INMP441 I2S mics, listo para medir direcciones de llegada en tiempo casi real y servir como base para beamforming activo (p. ej., rechazo de interferencias fuera de eje o realce de una fuente deseada).

Encuentra este producto y/o libros sobre este tema en Amazon

Ir a Amazon

Como afiliado de Amazon, gano con las compras que cumplan los requisitos. Si compras a través de este enlace, ayudas a mantener este proyecto.

Quiz rápido

Pregunta 1: ¿Cuál es la versión de PowerShell requerida para el sistema operativo Windows?




Pregunta 2: ¿Qué versión de PlatformIO Core se debe instalar?




Pregunta 3: ¿Qué plataforma y versión se recomienda para el proyecto PlatformIO?




Pregunta 4: ¿Cuál es el framework que se debe usar en el proyecto PlatformIO?




Pregunta 5: ¿Qué tipo de micrófonos se utilizan en el proyecto?




Pregunta 6: ¿Qué driver USB se necesita instalar en Windows para el CP210x?




Pregunta 7: ¿Qué distancia se recomienda entre los micrófonos en el separador rígido?




Pregunta 8: ¿Cuál es la versión recomendada de Python para PlatformIO?




Pregunta 9: ¿Qué tipo de cables se necesitan para conectar los micrófonos?




Pregunta 10: ¿Qué tipo de fuente se sugiere para fijar los micrófonos a la barra?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Ingeniero Superior en Electrónica de Telecomunicaciones e Ingeniero en Informática (titulaciones oficiales en España).

Sígueme:


Caso práctico: Pan-Tilt WebSocket OV2640 ESP32-CAM y PCA9685

Caso práctico: Pan-Tilt WebSocket OV2640 ESP32-CAM y PCA9685 — hero

Objetivo y caso de uso

Qué construirás: Un sistema de streaming de video utilizando ESP32-CAM y control de pan-tilt mediante PCA9685.

Para qué sirve

  • Monitoreo remoto de espacios utilizando video en tiempo real.
  • Control de cámaras en aplicaciones de robótica y drones.
  • Implementación de sistemas de seguridad con visualización dinámica.
  • Proyectos de domótica para vigilancia y control de áreas específicas.

Resultado esperado

  • Streaming de video a 30 FPS con latencia inferior a 200 ms.
  • Control de posición del servo con precisión de 1 grado.
  • Conexión estable a través de WebSocket con menos de 5% de pérdida de paquetes.
  • Tiempo de respuesta en el control de pan-tilt menor a 100 ms.

Público objetivo: Desarrolladores y entusiastas de IoT; Nivel: Avanzado

Arquitectura/flujo: Comunicación entre ESP32-CAM y servidor WebSocket, control de servos mediante PCA9685.

Nivel: Avanzado

Prerrequisitos

Sistemas operativos soportados y herramientas

  • Windows 10/11 (64-bit)
  • macOS 12/13/14 (Intel o Apple Silicon)
  • Ubuntu 22.04 LTS / Debian 12

Toolchain exacta (versionado cerrado)

  • IDE/CLI:
  • Visual Studio Code 1.93.x (opcional, recomendado para edición)
  • PlatformIO Core 6.1.12 (CLI)
  • Paquetes PlatformIO:
  • Plataforma: espressif32@6.7.0
  • Framework Arduino: framework-arduinoespressif32@3.20014.0 (equivale a Arduino-ESP32 2.0.14)
  • Compilador: toolchain-xtensa32 provisto por espressif32@6.7.0 (instalado automáticamente por PlatformIO)
  • Librerías (resueltas por PlatformIO):
  • me-no-dev/AsyncTCP@1.1.1
  • me-no-dev/ESP Async WebServer@1.2.3
  • adafruit/Adafruit PWM Servo Driver Library@2.4.2
  • adafruit/Adafruit BusIO@1.14.5
  • Python 3.11.x (requerido por PlatformIO Core)
  • Controladores USB-UART (según tu adaptador):
  • CP210x (Silicon Labs) o CH340/CH34x (WCH). Instala el driver oficial correspondiente si tu sistema no lo reconoce de forma nativa.

Instalación rápida de PlatformIO Core (CLI)

  • Windows/macOS/Linux (asumiendo Python 3.11 disponible en PATH):
  • Instalar:
    pip install -U platformio==6.1.12
  • Verificar:
    pio --version
    Debe mostrar: PlatformIO Core, version 6.1.12

Materiales

  • 1x ESP32-CAM (OV2640) — modelo AI-Thinker con cámara OV2640 integrada
  • 1x Módulo PCA9685 16 canales (servo driver I2C)
  • 2x Servos para pan-tilt (p. ej., SG90/MG90S). Si usas un soporte mecánico pan-tilt, típicamente canal 0 = pan, canal 1 = tilt
  • 1x Adaptador USB–UART 3.3 V (FTDI/CP2102/CH340) para programar la ESP32-CAM
  • 1x Fuente 5 V 2 A (para alimentar servos vía V+ del PCA9685)
  • Protoboard o base de conexiones y cables Dupont
  • 2–3 jumpers para modo de programación:
  • GPIO0 a GND (para entrar en bootloader)
  • RST a GND (botón o puente momentáneo para reinicio)
  • Cables para I2C:
  • SDA, SCL entre ESP32-CAM y PCA9685
  • Cables para alimentación:
  • VCC (3.3 V), 5 V, GND
  • Opcional: Estructura mecánica pan-tilt (Smart Car PTZ o similar)

Nota: Este caso práctico está centrado estrictamente en el modelo “ESP32-CAM (OV2640) + PCA9685 servo driver” y el objetivo “ov2640-websocket-pan-tilt”.

Preparación y conexión

En la ESP32-CAM (AI-Thinker), la cámara utiliza una serie de GPIOs internos; evitaremos usar la ranura microSD para poder disponer de GPIO14 y GPIO15 como bus I2C. No usaremos la microSD en este proyecto.

  • Bus I2C elegido:
  • SDA → GPIO15 de la ESP32-CAM
  • SCL → GPIO14 de la ESP32-CAM

  • Conexiones USB–UART para programación:

  • U0R (GPIO3) ← TX del adaptador USB–UART
  • U0T (GPIO1) → RX del adaptador USB–UART
  • 5V (ESP32-CAM) ← 5V del adaptador (si entrega suficiente) o fuente externa
  • GND (ESP32-CAM) ← GND del adaptador
  • GPIO0 ↔ GND (para entrar en modo de programación)
  • RST ↔ GND (pulsar momentáneamente para reiniciar)

  • PCA9685:

  • VCC (lógica) ← 3.3 V de la ESP32-CAM (acepta 3.3–5 V; usar 3.3 V para lógica segura)
  • V+ (potencia servos) ← 5 V de la fuente externa
  • GND ← GND común (ESP32-CAM, adaptador USB–UART, fuente 5 V)
  • SDA ← GPIO15 (ESP32-CAM)
  • SCL ← GPIO14 (ESP32-CAM)
  • Canales PWM:
    • CH0 (PCA9685) → Señal servo PAN
    • CH1 (PCA9685) → Señal servo TILT
  • Alimentación de servos:
    • Rojo (Vcc) → V+ 5 V del PCA9685
    • Marrón/Negro (GND) → GND del PCA9685
    • Amarillo/Blanco (Señal) → CH0/CH1 del PCA9685

Tabla de conexiones detallada:

Componente Pin/Señal Conecta a Notas
ESP32-CAM U0R (GPIO3) TXD (USB–UART) Programación/monitor serie
ESP32-CAM U0T (GPIO1) RXD (USB–UART) Programación/monitor serie
ESP32-CAM 5V 5V (USB–UART o fuente) Puede alimentar módulo, NO a los servos
ESP32-CAM GND GND común Referencia común
ESP32-CAM GPIO0 GND (solo al programar) Bootloader
ESP32-CAM RST Pulsador a GND Reset
ESP32-CAM GPIO15 (SDA) SDA (PCA9685) Bus I2C, 3.3 V
ESP32-CAM GPIO14 (SCL) SCL (PCA9685) Bus I2C, 3.3 V
PCA9685 VCC (lógica) 3.3 V (ESP32-CAM) Lógica 3.3 V
PCA9685 V+ (potencia) 5 V (fuente externa) Potencia de servos
PCA9685 GND GND común Referencia común
PCA9685 CH0 PWM Señal servo PAN Servo 1 (pan)
PCA9685 CH1 PWM Señal servo TILT Servo 2 (tilt)

Advertencias:
– No alimentes los servos desde el pin 5V de la ESP32-CAM. Usa el terminal V+ del PCA9685 con una fuente 5 V adecuada y GND común.
– Evita usar GPIO12 (MTDI) para señales externas; puede causar problemas de arranque.
– Si usas una fuente 5 V separada para V+, une las masas (GND) de fuente, PCA9685 y ESP32-CAM.

Código completo

A continuación se proporciona el proyecto completo con PlatformIO. El objetivo “ov2640-websocket-pan-tilt” crea un servidor WebSocket que:
– Transmite imágenes JPEG de la cámara OV2640 en binario a los clientes conectados.
– Recibe comandos WebSocket de texto para posicionar PAN/TILT mediante el PCA9685.
– Sirve una página HTML con un visor y controles para pan/tilt.

platformio.ini

Incluye versiones fijas y librerías necesarias.

; ov2640-websocket-pan-tilt/platformio.ini
[env:esp32cam]
platform = espressif32@6.7.0
board = esp32cam
framework = arduino
platform_packages =
  framework-arduinoespressif32@3.20014.0
board_build.partitions = huge_app.csv
monitor_speed = 115200
upload_speed = 921600
build_flags =
  -DCORE_DEBUG_LEVEL=1
lib_deps =
  me-no-dev/AsyncTCP@1.1.1
  me-no-dev/ESP Async WebServer@1.2.3
  adafruit/Adafruit PWM Servo Driver Library@2.4.2
  adafruit/Adafruit BusIO@1.14.5

src/main.cpp

Explicación rápida de las partes clave:
– Configuración de cámara para AI-Thinker (OV2640).
– Inicialización de WiFi STA con credenciales.
– Servidor HTTP (AsyncWebServer) y WebSocket en /ws.
– Tarea FreeRTOS que captura frames y los empuja a los clientes vía WebSocket usando buffers seguros.
– Control de servos con PCA9685 a 50 Hz, mapeando ángulos a microsegundos.
– Página HTML embebida con JS para visualizar el stream y emitir comandos PAN/TILT.

// ov2640-websocket-pan-tilt/src/main.cpp
#include <Arduino.h>
#include <WiFi.h>
#include <esp_camera.h>
#include <Wire.h>
#include <Adafruit_PWMServoDriver.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>

// ========= Configuración WiFi =========
static const char* WIFI_SSID = "TU_SSID";
static const char* WIFI_PASS = "TU_PASSWORD";

// ========= Pines cámara AI-Thinker (OV2640) =========
#define PWDN_GPIO_NUM     32
#define RESET_GPIO_NUM    -1
#define XCLK_GPIO_NUM      0
#define SIOD_GPIO_NUM     26
#define SIOC_GPIO_NUM     27
#define Y9_GPIO_NUM       35
#define Y8_GPIO_NUM       34
#define Y7_GPIO_NUM       39
#define Y6_GPIO_NUM       36
#define Y5_GPIO_NUM       21
#define Y4_GPIO_NUM       19
#define Y3_GPIO_NUM       18
#define Y2_GPIO_NUM        5
#define VSYNC_GPIO_NUM    25
#define HREF_GPIO_NUM     23
#define PCLK_GPIO_NUM     22

// ========= I2C PCA9685 =========
// Usamos GPIO15 (SDA) y GPIO14 (SCL) en la ESP32-CAM
#define I2C_SDA 15
#define I2C_SCL 14

// ========= PCA9685 y servo =========
Adafruit_PWMServoDriver pwm = Adafruit_PWMServoDriver(0x40); // Dirección I2C por defecto

// Frecuencia típica servos
const float SERVO_FREQ_HZ = 50.0f;

// Canales
const uint8_t PAN_CH  = 0;
const uint8_t TILT_CH = 1;

// Calibración µs (ajústalo a tus servos/mecánica)
const int SERVO_MIN_US = 500;   // pulso mínimo
const int SERVO_MAX_US = 2500;  // pulso máximo

// Almacenamos ángulos actuales/objetivo para suavizado
volatile int pan_target_deg  = 90;
volatile int tilt_target_deg = 90;

volatile int pan_current_deg  = 90;
volatile int tilt_current_deg = 90;

// ========= Web =========
AsyncWebServer server(80);
AsyncWebSocket ws("/ws");

static const char INDEX_HTML[] PROGMEM = R"HTML(
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>ov2640-websocket-pan-tilt</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
 body { font-family: system-ui, sans-serif; margin: 1rem; color: #222; }
 .row { display: flex; gap: 1.5rem; flex-wrap: wrap; }
 .panel { border: 1px solid #ccc; border-radius: 8px; padding: 1rem; }
 #img { width: 100%; max-width: 480px; background: #000; }
 .ctrl label { display:block; margin-top: .5rem; }
 .ctrl input[type=range] { width: 100%; }
 .status { font-size: .9rem; color: #555; }
 button { padding: .5rem 1rem; margin-top: .5rem; }
</style>
</head>
<body>
<h1>ov2640-websocket-pan-tilt</h1>
<div class="row">
  <div class="panel">
    <img id="img" alt="stream" />
    <div class="status" id="st">Conectando…</div>
  </div>
  <div class="panel ctrl">
    <label>Pan: <span id="panv">90</span>°
      <input id="pan" type="range" min="0" max="180" value="90" />
    </label>
    <label>Tilt: <span id="tiltv">90</span>°
      <input id="tilt" type="range" min="0" max="180" value="90" />
    </label>
    <button id="center">Centrar (90°, 90°)</button>
  </div>
</div>
<script>
(() => {
  const img = document.getElementById('img');
  const st  = document.getElementById('st');
  const pan = document.getElementById('pan');
  const tilt= document.getElementById('tilt');
  const panv= document.getElementById('panv');
  const tiltv= document.getElementById('tiltv');
  const center = document.getElementById('center');

  let ws;
  function connect() {
    const proto = location.protocol === 'https:' ? 'wss' : 'ws';
    ws = new WebSocket(`${proto}://${location.host}/ws`);
    ws.binaryType = 'arraybuffer';
    ws.onopen = () => st.textContent = 'Conectado';
    ws.onclose = () => { st.textContent = 'Desconectado. Reintentando…'; setTimeout(connect, 2000); };
    ws.onerror = () => { st.textContent = 'Error WS'; };

    ws.onmessage = (ev) => {
      if (typeof ev.data !== 'string') {
        const blob = new Blob([ev.data], { type: 'image/jpeg' });
        const url = URL.createObjectURL(blob);
        img.onload = () => URL.revokeObjectURL(url);
        img.src = url;
      } else {
        // Mensajes de texto del servidor (estado)
        st.textContent = ev.data;
      }
    };
  }
  connect();

  function sendCmd(cmd) {
    if (ws && ws.readyState === 1) ws.send(cmd);
  }

  pan.addEventListener('input', () => {
    panv.textContent = pan.value;
    sendCmd(`PAN:${pan.value}`);
  });
  tilt.addEventListener('input', () => {
    tiltv.textContent = tilt.value;
    sendCmd(`TILT:${tilt.value}`);
  });
  center.addEventListener('click', () => {
    pan.value = 90; tilt.value = 90;
    panv.textContent = '90'; tiltv.textContent = '90';
    sendCmd('PAN:90'); sendCmd('TILT:90');
  });
})();
</script>
</body>
</html>
)HTML";

// ========= Utilidades =========
static inline int clampi(int v, int lo, int hi) { return v < lo ? lo : (v > hi ? hi : v); }

void setServoAngle(uint8_t ch, int angleDeg) {
  angleDeg = clampi(angleDeg, 0, 180);
  const int us = SERVO_MIN_US + (int)((SERVO_MAX_US - SERVO_MIN_US) * (angleDeg / 180.0f));
  pwm.writeMicroseconds(ch, us);
}

void servoSmootherTask(void* arg) {
  const TickType_t interval = pdMS_TO_TICKS(15); // ~66 actualizaciones/s
  for (;;) {
    int p = pan_current_deg, pt = pan_target_deg;
    int t = tilt_current_deg, tt = tilt_target_deg;

    if (p != pt) {
      p += (pt > p) ? 1 : -1;
      pan_current_deg = p;
      setServoAngle(PAN_CH, p);
    }
    if (t != tt) {
      t += (tt > t) ? 1 : -1;
      tilt_current_deg = t;
      setServoAngle(TILT_CH, t);
    }
    vTaskDelay(interval);
  }
}

void wsOnEvent(AsyncWebSocket * server, AsyncWebSocketClient * client, 
               AwsEventType type, void * arg, uint8_t * data, size_t len) {
  if (type == WS_EVT_CONNECT) {
    client->text("WS conectado. Envíe PAN:<0..180> / TILT:<0..180>");
  } else if (type == WS_EVT_DATA) {
    AwsFrameInfo* info = (AwsFrameInfo*)arg;
    if (info->opcode == WS_TEXT) {
      String msg;
      msg.reserve(len);
      for (size_t i = 0; i < len; i++) msg += (char)data[i];

      // Formato esperado: "PAN:123" o "TILT:45"
      msg.trim();
      int sep = msg.indexOf(':');
      if (sep > 0) {
        String key = msg.substring(0, sep);
        String val = msg.substring(sep + 1);
        int angle = clampi(val.toInt(), 0, 180);
        if (key.equalsIgnoreCase("PAN")) {
          pan_target_deg = angle;
          client->text("PAN->" + String(angle));
        } else if (key.equalsIgnoreCase("TILT")) {
          tilt_target_deg = angle;
          client->text("TILT->" + String(angle));
        } else {
          client->text("CMD desconocido");
        }
      } else {
        client->text("Formato inválido");
      }
    }
  }
}

TaskHandle_t frameTaskHandle = nullptr;

void frameSenderTask(void* arg) {
  const TickType_t period = pdMS_TO_TICKS(100); // ~10 fps
  for (;;) {
    if (ws.count() > 0) {
      camera_fb_t* fb = esp_camera_fb_get();
      if (!fb) {
        // Notificar error eventual
        ws.textAll("Error: no frame");
      } else {
        // Crear buffer gestionado por AsyncWebSocket
        auto buf = ws.makeBuffer(fb->len);
        if (buf) {
          memcpy(buf->get(), fb->buf, fb->len);
          ws.binaryAll(buf);
        }
        esp_camera_fb_return(fb);
      }
    }
    vTaskDelay(period);
  }
}

bool initCamera() {
  camera_config_t config;
  config.ledc_channel = LEDC_CHANNEL_0;
  config.ledc_timer   = LEDC_TIMER_0;
  config.pin_d0       = Y2_GPIO_NUM;
  config.pin_d1       = Y3_GPIO_NUM;
  config.pin_d2       = Y4_GPIO_NUM;
  config.pin_d3       = Y5_GPIO_NUM;
  config.pin_d4       = Y6_GPIO_NUM;
  config.pin_d5       = Y7_GPIO_NUM;
  config.pin_d6       = Y8_GPIO_NUM;
  config.pin_d7       = Y9_GPIO_NUM;
  config.pin_xclk     = XCLK_GPIO_NUM;
  config.pin_pclk     = PCLK_GPIO_NUM;
  config.pin_vsync    = VSYNC_GPIO_NUM;
  config.pin_href     = HREF_GPIO_NUM;
  config.pin_sscb_sda = SIOD_GPIO_NUM;
  config.pin_sscb_scl = SIOC_GPIO_NUM;
  config.pin_pwdn     = PWDN_GPIO_NUM;
  config.pin_reset    = RESET_GPIO_NUM;
  config.xclk_freq_hz = 20000000; // 20 MHz típico
  config.pixel_format = PIXFORMAT_JPEG;

  if (psramFound()) {
    config.frame_size   = FRAMESIZE_QVGA; // 320x240
    config.jpeg_quality = 12;             // 10–20 es razonable
    config.fb_count     = 2;
    config.grab_mode    = CAMERA_GRAB_LATEST;
  } else {
    config.frame_size   = FRAMESIZE_QQVGA; // por si no hay PSRAM
    config.jpeg_quality = 15;
    config.fb_count     = 1;
    config.grab_mode    = CAMERA_GRAB_WHEN_EMPTY;
  }

  esp_err_t err = esp_camera_init(&config);
  if (err != ESP_OK) {
    Serial.printf("camera_init failed: 0x%x\n", err);
    return false;
  }
  sensor_t* s = esp_camera_sensor_get();
  // Ajustes opcionales del sensor
  s->set_brightness(s, 0);
  s->set_contrast(s, 0);
  s->set_saturation(s, 0);
  s->set_framesize(s, config.frame_size);

  return true;
}

void setup() {
  Serial.begin(115200);
  delay(200);

  // I2C para PCA9685
  Wire.begin(I2C_SDA, I2C_SCL, 400000); // 400 kHz
  if (!pwm.begin()) {
    Serial.println("ERROR: PCA9685 no responde en 0x40");
  }
  pwm.setOscillatorFrequency(27000000); // 27 MHz nominal
  pwm.setPWMFreq(SERVO_FREQ_HZ);
  delay(10);
  setServoAngle(PAN_CH, pan_current_deg);
  setServoAngle(TILT_CH, tilt_current_deg);

  // Cámara OV2640
  if (!initCamera()) {
    Serial.println("Fallo al iniciar cámara. Revise cableado y PSRAM.");
    // no return; permitimos que el sistema arranque para debug
  }

  // WiFi
  WiFi.mode(WIFI_STA);
  WiFi.setSleep(false);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  Serial.printf("Conectando a WiFi SSID='%s'\n", WIFI_SSID);
  unsigned long t0 = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - t0 < 20000) {
    delay(300);
    Serial.print(".");
  }
  Serial.println();
  if (WiFi.status() == WL_CONNECTED) {
    Serial.printf("WiFi OK. IP: %s\n", WiFi.localIP().toString().c_str());
  } else {
    Serial.println("WiFi no conectado (timeout). Continúa en AP del router o revise credenciales.");
  }

  // Servidor HTTP y WebSocket
  ws.onEvent(wsOnEvent);
  server.addHandler(&ws);

  server.on("/", HTTP_GET, [](AsyncWebServerRequest *req){
    AsyncWebServerResponse* res = req->beginResponse_P(200, "text/html; charset=utf-8", INDEX_HTML, strlen(INDEX_HTML));
    res->addHeader("Cache-Control", "no-store");
    req->send(res);
  });

  server.on("/favicon.ico", HTTP_GET, [](AsyncWebServerRequest* req){ req->send(204); });

  server.begin();
  Serial.println("Servidor HTTP/WS listo.");

  // Tarea de suavizado servo
  xTaskCreatePinnedToCore(servoSmootherTask, "servoSmoother", 2048, nullptr, 1, nullptr, 1);

  // Tarea de envío de frames
  xTaskCreatePinnedToCore(frameSenderTask, "frameSender", 4096, nullptr, 1, &frameTaskHandle, 1);
}

void loop() {
  // AsyncWebServer no requiere loop pesado; el WS se atiende en callbacks
  delay(50);
}

Puntos clave:
– Se usan GPIO14 (SCL) y GPIO15 (SDA) para I2C, liberados al no usar la microSD.
– La tarea frameSender copia cada JPEG a un buffer administrado por AsyncWebSocket (ws.makeBuffer) antes de enviar, evitando usar el buffer de la cámara tras esp_camera_fb_return.
– Los comandos de control PAN/TILT son texto simple “PAN:<0..180>” y “TILT:<0..180>”.
– Un “servoSmootherTask” evita saltos bruscos en mecánicas pan-tilt.

Compilación, flash y ejecución

Asegúrate de poner GPIO0 a GND y reiniciar (RST→GND momentáneo) para entrar en bootloader antes de cargar el firmware.

1) Crear el proyecto PlatformIO:
– Con PlatformIO Core CLI:
mkdir ov2640-websocket-pan-tilt
cd ov2640-websocket-pan-tilt
pio project init --board esp32cam

– Sustituye el contenido del platformio.ini generado por el provisto en este caso.
– Crea el árbol de fuentes:
mkdir -p src
# Coloca el main.cpp provisto en src/main.cpp

2) Compilar:
pio run

3) Detectar el puerto serie (opcional):
pio device list

4) Poner la ESP32-CAM en modo programación:
– Conectar GPIO0 a GND.
– Reiniciar pulsando RST a GND y soltando.
– Verifica que el adaptador USB–UART está a 3.3 V (nivel lógico), y que TX/RX están cruzados correctamente.

5) Subir firmware:
pio run -t upload

6) Desconectar el modo programación:
– Desconecta GPIO0 de GND.
– Reinicia la placa (RST a GND momentáneo).

7) Monitor serie:
pio device monitor -b 115200
Observa el IP asignado por tu router (p. ej., 192.168.1.123).

8) Abrir la aplicación:
– En tu navegador, accede a:
– http:///
– Debes ver el visor con el streaming y sliders para PAN/TILT.

Notas:
– Si la subida falla, repite el paso 4 y 5. Algunos adaptadores requieren bajar la velocidad de upload:
pio run -t upload --upload-port COMx
o ajusta upload_speed en platformio.ini (p. ej., 460800).

Validación paso a paso

1) Alimentación y conexiones:
– La ESP32-CAM enciende y el LED rojo está estable.
– El PCA9685 recibe 3.3 V en VCC y 5 V en V+, con GND común.
– Los servos están conectados a CH0 y CH1 del PCA9685, con V+ a 5 V.

2) Arranque y logs:
– En el monitor serie, tras un reset sin GPIO0 a GND, verás:
– Mensajes de “Conectando a WiFi…” seguido de “WiFi OK. IP: x.x.x.x”.
– “Servidor HTTP/WS listo.”
– Si la cámara se inicializa: no aparece “camera_init failed”.

3) Acceso web:
– En la URL http:/// se carga la página.
– El estado pasa de “Conectando…” a “Conectado”.
– Debes ver refrescarse la imagen JPEG constantemente (≈10 fps).

4) Control pan-tilt:
– Al mover el slider de “Pan”, el servo correspondiente debe responder con un pequeño retardo (suavizado 1°/15 ms).
– Al mover el slider de “Tilt”, el segundo servo debe responder.
– El botón “Centrar” debe posicionar ambos a 90°.

5) WebSocket:
– Abre las herramientas de desarrollador del navegador (F12 → Network → WS):
– Observa que /ws está en estado abierto.
– Debe recibir frames binarios (tipo ArrayBuffer).
– Los mensajes de texto del servidor confirman cambios (“PAN->X”, “TILT->Y”).

6) Calidad de imagen:
– La imagen debe ser nítida en QVGA (320×240). Si necesitas más, puedes aumentar a VGA, pero observarás mayor latencia/uso de RAM.

7) Temperatura y consumo:
– La ESP32-CAM puede calentarse ligeramente; es normal.
– Los servos no deben zumbar en exceso en reposo. Si lo hacen, reduce el rango o calibra µs.

8) Persistencia:
– Desconecta y reconecta la alimentación. El sistema debe reanudar conexión al WiFi y servir la interfaz sin intervención adicional.

Troubleshooting

1) Error de alimentación/“Brownout detector”
– Síntomas: reinicios, mensajes “Brownout detector was triggered”.
– Causa: Caídas en 5 V cuando los servos arrancan o se bloquean mecánicamente.
– Solución:
– Alimenta los servos desde una fuente 5 V 2 A (mínimo) separada del 5 V del USB.
– Asegura GND común entre ESP32-CAM, PCA9685 y fuente 5 V.
– Añade condensadores cerca del PCA9685 y servos (p. ej., 470–1000 µF en V+ a GND).

2) “camera_init failed: 0x…” o imagen negra
– Revisa el mapeo de pines (este caso usa AI-Thinker por defecto).
– Asegúrate de no usar la ranura microSD en paralelo (hemos tomado GPIO14/15 para I2C).
– Reduce frame_size a QQVGA y/o baja jpeg_quality si hay poca RAM.
– Confirma que la cámara está bien insertada en su conector.

3) El PCA9685 no responde (“ERROR: PCA9685 no responde en 0x40”)
– Comprueba SDA=SDA y SCL=SCL (GPIO15 y GPIO14 respectivamente).
– Verifica VCC=3.3 V y GND.
– Si tu módulo PCA9685 tiene jumpers de dirección A0–A5, confirma que la dirección es 0x40.
– Usa un escáner I2C si es necesario (temporalmente) para listar direcciones.

4) El stream WebSocket se congela o es muy lento
– Baja la resolución a QQVGA o sube jpeg_quality (número mayor reduce tamaño).
– Asegúrate de tener buena señal WiFi; aproxima el router o usa otro canal menos congestionado.
– Controla el periodo de envío (frameSenderTask) para evitar saturación (p. ej., 100–150 ms).

5) Fallo al subir firmware (“A fatal error occurred: Failed to connect to ESP32”)
– Asegúrate de:
– GPIO0 a GND durante la carga.
– Pulsar RST (a GND) justo antes de “upload”.
– RX/TX están cruzados (TX del adaptador a U0R/GPIO3; RX a U0T/GPIO1).
– Baja upload_speed a 460800 o 115200 en platformio.ini si el adaptador es inestable.

6) Servos vibran, zumban o no se mueven correctamente
– Revisa SERVO_MIN_US y SERVO_MAX_US para tu modelo de servo; prueba 600–2400 µs.
– Elimina topes mecánicos: no fuerces el mecanismo más allá de su rango físico.
– Evita enviar comandos a 1000 Hz: el suavizado ya limita la cadencia.
– Asegúrate de V+ estable a 5 V y GND común.

7) La página web carga, pero no hay imagen
– Revisa que el browser no bloquea contenido mixto si accedes vía HTTPS en otra pestaña.
– Verifica en DevTools → Network → WS que /ws está abierto.
– Si hay errores CORS u otros, recarga y limpia cache (Ctrl+F5).

8) Reinicios aleatorios (WDT) al mover sliders
– Asegúrate de no bloquear callbacks WS con lógica pesada.
– El envío de frames usa buffers gestionados; si cambiaste a otra API, cuida la vida de los punteros.
– Verifica que la fuente 5 V no cae con el movimiento de los servos.

Mejoras/variantes

  • Streaming MJPEG por HTTP:
  • Alternativa a WebSocket binario para compatibilidad con visores MJPEG nativos de navegadores y herramientas. Aumenta la compatibilidad a costa de menos control bidireccional.
  • WSS y autenticación:
  • Añade TLS con un proxy inverso (Nginx/Caddy) y credenciales para restringir el acceso.
  • Controles avanzados:
  • Implementar trayectorias suaves (easing) y límites de velocidad/ aceleración para no castigar la mecánica.
  • Resoluciones y ROI:
  • Cambiar a FRAMESIZE_VGA con PSRAM y reducir fps si tu red lo permite. O implementar región de interés (ROI) si solo interesa una zona.
  • Calibración interactiva:
  • Permite guardar en NVS los parámetros SERVO_MIN_US/SERVO_MAX_US y ángulos máximos seguros por cada servo.
  • OTA (Over-The-Air):
  • Agregar ArduinoOTA o AsyncElegantOTA para actualizar sin cable una vez desplegado.
  • Telemetría:
  • Añade un canal WS de estado (temperatura, fps efectivo, latencia medida por marca de tiempo) para depuración avanzada.
  • Node-RED / Control externo:
  • Publicar un endpoint WS o HTTP con API para integrar con dashboards externos.

Checklist de verificación

  • [ ] Toolchain instalado:
  • [ ] PlatformIO Core 6.1.12 (pio –version)
  • [ ] Plataforma espressif32@6.7.0 y Arduino-ESP32 2.0.14 (via platformio.ini)
  • [ ] Drivers USB–UART instalados (CP210x/CH34x) y puerto visible en “pio device list”
  • [ ] Cableado correcto:
  • [ ] TX (USB–UART) → U0R (GPIO3), RX (USB–UART) ← U0T (GPIO1)
  • [ ] GPIO0 a GND al programar; suelto para ejecutar
  • [ ] PCA9685: VCC=3.3 V, V+=5 V, GND común
  • [ ] I2C: SDA=GPIO15, SCL=GPIO14
  • [ ] Servos: señal en CH0/CH1, V+ a 5 V, GND común
  • [ ] Compilación OK: “pio run” sin errores
  • [ ] Carga OK: “pio run -t upload” con GPIO0 a GND y reset
  • [ ] Monitor serie (115200) muestra IP tras conectar al WiFi
  • [ ] Página accesible en http:///
  • [ ] WebSocket conectado (“Conectado” en UI)
  • [ ] Imagen de la cámara visible y actualizándose
  • [ ] Deslizadores mueven PAN/TILT como se espera
  • [ ] Sin “Brownout” ni reinicios al mover servos
  • [ ] Fuente 5 V disipando adecuadamente y sin calentamiento excesivo

Con este caso práctico has construido un sistema “ov2640-websocket-pan-tilt” completamente reproducible sobre el modelo exacto “ESP32-CAM (OV2640) + PCA9685 servo driver”, usando PlatformIO con versiones fijadas. Has cubierto desde la preparación del entorno, cableado y programación hasta la validación, resolución de problemas y posibles mejoras.

Encuentra este producto y/o libros sobre este tema en Amazon

Ir a Amazon

Como afiliado de Amazon, gano con las compras que cumplan los requisitos. Si compras a través de este enlace, ayudas a mantener este proyecto.

Quiz rápido

Pregunta 1: ¿Cuál es el sistema operativo soportado por PlatformIO Core?




Pregunta 2: ¿Qué versión de Python es requerida por PlatformIO Core?




Pregunta 3: ¿Qué herramienta se recomienda para la edición del código?




Pregunta 4: ¿Cuál es la versión del compilador que se instala automáticamente por PlatformIO?




Pregunta 5: ¿Qué módulo se necesita para controlar servos mediante I2C?




Pregunta 6: ¿Cuál es la versión de la plataforma espressif32 mencionada?




Pregunta 7: ¿Qué tipo de adaptador USB se menciona para programar la ESP32-CAM?




Pregunta 8: ¿Qué librería se necesita para utilizar el ESP Async WebServer?




Pregunta 9: ¿Qué tipo de servos se mencionan para el pan-tilt?




Pregunta 10: ¿Qué es necesario instalar para utilizar PlatformIO Core en Windows/macOS/Linux?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Ingeniero Superior en Electrónica de Telecomunicaciones e Ingeniero en Informática (titulaciones oficiales en España).

Sígueme:


Caso práctico: Logger de energía Modbus Ethernet ESP32 RS485

Caso práctico: Logger de energía Modbus Ethernet ESP32 RS485 — hero

Objetivo y caso de uso

Qué construirás: Un sistema de registro de energía utilizando un ESP32-Ethernet-Kit como maestro Modbus RTU para capturar y exponer datos de consumo energético a través de Ethernet.

Para qué sirve

  • Monitoreo en tiempo real de consumo energético en instalaciones industriales.
  • Registro de datos históricos para análisis de eficiencia energética.
  • Integración con sistemas de gestión de energía mediante protocolo Modbus.
  • Control remoto de dispositivos eléctricos basado en datos de consumo.
  • Generación de alertas sobre consumos anómalos a través de MQTT.

Resultado esperado

  • Captura de datos de consumo en tiempo real con una frecuencia de 1 segundo.
  • Latencia de respuesta del sistema inferior a 100 ms en la comunicación Modbus.
  • Exposición de datos a través de Ethernet con un throughput de 500 paquetes/s.
  • Registro de datos con un margen de error inferior al 1% en comparación con medidores de referencia.
  • Generación de informes semanales sobre consumo energético con métricas detalladas.

Público objetivo: Ingenieros y desarrolladores de sistemas embebidos; Nivel: Avanzado

Arquitectura/flujo: Comunicación Modbus RTU sobre RS485 hacia el ESP32, procesamiento de datos y exposición a través de Ethernet.

Nivel: Avanzado

Prerrequisitos

Sistemas operativos soportados y versiones probadas

  • Windows 11 Pro 23H2 (build 22631)
  • Ubuntu 22.04.5 LTS (Jammy)
  • macOS 14.5 (Sonoma)

En los tres entornos se validará compilación, flasheo y monitor serie.

Toolchain exacta (versiones fijas)

  • PlatformIO Core (CLI): 6.1.14
  • Python: 3.11.6
  • Plataforma Espressif32 (PlatformIO): espressif32@6.9.0
  • Framework Arduino-ESP32: framework-arduinoespressif32@3.20014.0 (equivale a Arduino-ESP32 2.0.14)
  • Toolchain GCC Xtensa (ESP32): toolchain-xtensa-esp32@8.4.0+2021r2-patch5 (gcc 8.4.0)
  • esptool.py (empacado por PlatformIO): tool-esptoolpy@1.40501.0 (esptool.py 4.5.1)
  • OpenOCD-ESP32 (opcional para debug JTAG): 0.12.0-esp32-20230921 (no necesario para este caso)
  • Librerías de proyecto:
  • 4-20ma/ModbusMaster@2.0.1
  • bblanchon/ArduinoJson@6.21.2

Drivers USB-UART

  • El ESP32-Ethernet-Kit suele integrar un puente USB-UART Silicon Labs CP2102/CP210x.
  • Alternativamente, algunos kits pueden usar CH34x.
  • Instalar drivers:
  • Windows: CP210x Universal Windows Driver v10.1.x o CH34x Driver v3.6.x
  • macOS: CP210x VCP Driver 6.0.x (firmado) o CH34x 1.7.x
  • Linux: normalmente no requiere instalación; verificar permisos udev.

Requisitos de red

  • Red Ethernet con DHCP habilitado (recomendado para primeras pruebas).
  • Cable RJ45 Cat5e o superior.
  • Opcional: IP estática conocida para pruebas posteriores.

Verificaciones rápidas

  • Comandos de comprobación (ejecutar en terminal):
  • Windows (PowerShell):
    • pio –version
    • python –version
  • Linux/macOS:
    • pio –version
    • python3 –version
  • Versiones esperadas:
  • PlatformIO Core, salida similar a: PlatformIO Core, version 6.1.14
  • Python 3.11.6

Materiales

  • Placa: ESP32-Ethernet-Kit + LAN8720 + MAX3485 RS485 (exactamente este modelo de dispositivo)
  • ESP32-Ethernet-Kit (ESP32-WROOM-32E; PHY LAN8720 en modo RMII; reloj por GPIO17)
  • Transceptor RS485: MAX3485 (módulo TTL↔RS485, 3.3 V)
  • Cable micro-USB de datos (no solo carga)
  • Cable Ethernet RJ45
  • Fuente de alimentación 5 V (si no se alimenta por USB)
  • Medidor de energía con interfaz Modbus RTU/RS485 (p. ej., Eastron SDM120/SDM230/SDM630 o similar)
  • Resistencias de terminación RS485 de 120 Ω (en ambos extremos si el bus lo requiere)
  • Cables dupont macho-hembra para conexión del MAX3485 a GPIOs del ESP32
  • PC con uno de los OS soportados y PlatformIO instalado

Notas:
– Mantendremos coherencia total con el modelo “ESP32-Ethernet-Kit + LAN8720 + MAX3485 RS485” en conexiones, código y comandos.
– El objetivo del proyecto es “modbus-energy-logger-ethernet”: leer parámetros energéticos vía Modbus RTU/RS485 y publicarlos por Ethernet (HTTP/JSON), con trazas y validación.

Preparación y conexión

Reglas udev (Linux) y puertos serie

  • En Linux, añadir reglas udev si no existen:
# Copiar reglas de PlatformIO
wget https://raw.githubusercontent.com/platformio/platformio-core/develop/scripts/99-platformio-udev.rules -O /tmp/99-platformio-udev.rules
sudo cp /tmp/99-platformio-udev.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules
sudo udevadm trigger
  • Desconectar y reconectar el ESP32-Ethernet-Kit por USB.
  • Verificar puerto (ej.: /dev/ttyUSB0 o /dev/tty.SLAB_USBtoUART en macOS, COMx en Windows).

Mapeo de pines y conexión física

Tabla de pines relevantes en ESP32-Ethernet-Kit (LAN8720 y RS485 con MAX3485):

Función ESP32 GPIO Uso/Nota
ETH RMII MDC GPIO23 Línea de gestión PHY
ETH RMII MDIO GPIO18 Línea de gestión PHY
ETH PHY dirección 0 PHY_ADDR = 0 (por defecto en el kit)
ETH clock RMII GPIO17 Modo ETH_CLOCK_GPIO17_OUT (50 MHz output)
ETH TXD0/TXD1/TXEN 19/22/21 Cableadas en la placa (no cambiar)
ETH RXD0/RXD1/CRS_DV 25/26/27 Cableadas en la placa (no cambiar)
UART2 TX (RS485 DI) GPIO4 Conectar a DI del MAX3485
UART2 RX (RS485 RO) GPIO5 Conectar a RO del MAX3485
RS485 DE/RE (control TX/RX) GPIO15 Conectar a DE y RE unidos (DE=RE) en el MAX3485
3V3 3V3 Alimentación del MAX3485 (3.3 V)
GND GND Masa común con MAX3485 y medidor
USB-UART CP210x Programación y monitor serie

Conexiones RS485 (MAX3485 ↔ ESP32-Ethernet-Kit):
– MAX3485 DI → ESP32 GPIO4 (TX2)
– MAX3485 RO → ESP32 GPIO5 (RX2)
– MAX3485 DE y RE unidos → ESP32 GPIO15
– MAX3485 VCC → 3.3 V del ESP32-Ethernet-Kit
– MAX3485 GND → GND del ESP32-Ethernet-Kit
– RS485 A ↔ A (D+) del medidor
– RS485 B ↔ B (D−) del medidor
– Terminar bus con 120 Ω en los extremos si la topología lo requiere.

Conexión Ethernet:
– Conectar cable RJ45 al puerto del LAN8720 del ESP32-Ethernet-Kit y al switch/router con DHCP.

Atención a GPIOs reservados por RMII:
– No reusar 17, 18, 19, 21, 22, 23, 25, 26, 27 para otros fines.
– Evitar manipular GPIO0/2/12/15 en el arranque (son strap); en nuestro caso usaremos GPIO15 para DE/RE, manteniéndolo en LOW por defecto durante el boot.

Código completo

A continuación se muestra un proyecto funcional con PlatformIO usando Arduino-ESP32 2.0.14 y las librerías indicadas. Implementa:
– Inicialización Ethernet (LAN8720) con DHCP.
– Maestro Modbus RTU por RS485 (MAX3485) para leer parámetros típicos de un medidor (direcciones compatibles con SDM120/230/630 y similares).
– Servidor HTTP (WebServer) con endpoints:
– / (tabla HTML)
– /json (salida JSON)
– /metrics (formato estilo Prometheus, opcional)
– Bucle de sondeo periódico y cacheo de últimas lecturas.

platformio.ini

[env:esp32-ethernet-kit]
platform = espressif32@6.9.0
board = esp32-ethernet-kit
framework = arduino

platform_packages =
    platformio/tool-esptoolpy @ 1.40501.0
    platformio/toolchain-xtensa-esp32 @ 8.4.0+2021r2-patch5
    platformio/framework-arduinoespressif32 @ 3.20014.0

monitor_speed = 115200

lib_deps =
    4-20ma/ModbusMaster @ 2.0.1
    bblanchon/ArduinoJson @ 6.21.2

src/main.cpp

#include <Arduino.h>
#include <ETH.h>
#include <WebServer.h>
#include <ModbusMaster.h>
#include <ArduinoJson.h>

// -------------------- Configuración de hardware --------------------
static constexpr int PIN_RS485_TX = 4;     // UART2 TX -> MAX3485 DI
static constexpr int PIN_RS485_RX = 5;     // UART2 RX -> MAX3485 RO
static constexpr int PIN_RS485_REDE = 15;  // RS485 DE/RE (unidos) -> control TX/RX

// LAN8720 (ESP32-Ethernet-Kit) - RMII
static constexpr int ETH_PHY_ADDR = 0;               // Dirección por defecto del LAN8720
static constexpr int ETH_POWER_PIN = -1;             // Control de energía no usado (interno)
static constexpr int ETH_MDC_PIN = 23;               // MDC
static constexpr int ETH_MDIO_PIN = 18;              // MDIO
static constexpr eth_phy_type_t ETH_PHY_TYPE = ETH_PHY_LAN8720;
static constexpr eth_clock_mode_t ETH_CLK_MODE = ETH_CLOCK_GPIO17_OUT;

// -------------------- Configuración Modbus y medidor --------------------
// Parámetros de bus Modbus RTU típicos (ajustar si su medidor usa otros):
static uint8_t MODBUS_SLAVE_ID = 1;     // Dirección del medidor en el bus
static uint32_t MODBUS_BAUD = 9600;     // Baud rate
static uint16_t MODBUS_TIMEOUT_MS = 300; // Timeout por transacción

// Registros de ejemplo (Eastron SDM* Input Registers: floats 32-bit, 2 registros cada uno)
static constexpr uint16_t REG_VOLTAGE = 0x0000;      // V (2 regs)
static constexpr uint16_t REG_CURRENT = 0x0006;      // A (2 regs)
static constexpr uint16_t REG_ACTIVE_POWER = 0x000C; // W (2 regs)
static constexpr uint16_t REG_POWER_FACTOR = 0x001E; // PF (2 regs)
static constexpr uint16_t REG_FREQUENCY = 0x0046;    // Hz (2 regs)
static constexpr uint16_t REG_IMPORT_ACTIVE_ENERGY = 0x0048; // kWh (2 regs)

// Endianness común en muchos medidores SDM: word swap (lo-hi o hi-lo).
// Ajuste a true si el medidor requiere intercambiar palabras.
static bool FLOAT_WORD_SWAP = true;

// Periodo de sondeo
static uint32_t POLL_INTERVAL_MS = 1000;

// -------------------- Objetos globales --------------------
HardwareSerial RS485(2);        // UART2
ModbusMaster modbus;            // Maestro Modbus RTU
WebServer server(80);

// Variables de estado Ethernet
volatile bool eth_connected = false;
IPAddress eth_ip;

// Cache de lecturas
struct EnergyData {
  float voltage = NAN;
  float current = NAN;
  float active_power = NAN;
  float power_factor = NAN;
  float frequency = NAN;
  float import_active_energy = NAN;
  uint64_t last_update_ms = 0;
  uint32_t ok_count = 0;
  uint32_t err_count = 0;
} data;

// -------------------- RS485 control --------------------
void preTransmission() {
  digitalWrite(PIN_RS485_REDE, HIGH); // Habilitar TX
  delayMicroseconds(10);
}
void postTransmission() {
  delayMicroseconds(10);
  digitalWrite(PIN_RS485_REDE, LOW); // Volver a RX
}

// -------------------- Utilidades Modbus --------------------
bool readFloatInputRegister(uint16_t reg, float &out) {
  modbus.setSlave(MODBUS_SLAVE_ID);
  uint8_t result = modbus.readInputRegisters(reg, 2);
  if (result == modbus.ku8MBSuccess) {
    uint16_t w0 = modbus.getResponseBuffer(0);
    uint16_t w1 = modbus.getResponseBuffer(1);
    uint32_t raw = 0;
    if (FLOAT_WORD_SWAP) {
      raw = ((uint32_t)w1 << 16) | w0;
    } else {
      raw = ((uint32_t)w0 << 16) | w1;
    }
    float f;
    memcpy(&f, &raw, sizeof(f));
    out = f;
    return true;
  }
  return false;
}

// -------------------- HTTP Handlers --------------------
String htmlEscape(const String &s) {
  String r;
  r.reserve(s.length());
  for (char c : s) {
    switch (c) {
      case '&': r += "&amp;"; break;
      case '<': r += "&lt;"; break;
      case '>': r += "&gt;"; break;
      case '"': r += "&quot;"; break;
      default: r += c;
    }
  }
  return r;
}

void handleRoot() {
  String ip = eth_connected ? eth_ip.toString() : String("desconectado");
  String page;
  page.reserve(2048);
  page += "<!doctype html><html><head><meta charset='utf-8'><title>modbus-energy-logger-ethernet</title>";
  page += "<meta name='viewport' content='width=device-width,initial-scale=1'>";
  page += "<style>body{font-family:system-ui,Arial;margin:20px}table{border-collapse:collapse}td,th{border:1px solid #ccc;padding:6px 10px}</style>";
  page += "</head><body>";
  page += "<h1>modbus-energy-logger-ethernet</h1>";
  page += "<p>IP (ETH): " + htmlEscape(ip) + "</p>";
  page += "<table><tr><th>Métrica</th><th>Valor</th><th>Unidad</th></tr>";
  page += "<tr><td>Tensión</td><td>" + String(data.voltage, 3) + "</td><td>V</td></tr>";
  page += "<tr><td>Corriente</td><td>" + String(data.current, 3) + "</td><td>A</td></tr>";
  page += "<tr><td>Potencia Activa</td><td>" + String(data.active_power, 3) + "</td><td>W</td></tr>";
  page += "<tr><td>Factor de Potencia</td><td>" + String(data.power_factor, 3) + "</td><td>pf</td></tr>";
  page += "<tr><td>Frecuencia</td><td>" + String(data.frequency, 3) + "</td><td>Hz</td></tr>";
  page += "<tr><td>Energía Importada</td><td>" + String(data.import_active_energy, 3) + "</td><td>kWh</td></tr>";
  page += "</table>";
  page += "<p>Última actualización: " + String(data.last_update_ms) + " ms desde boot</p>";
  page += "<p>OK: " + String(data.ok_count) + " | ERR: " + String(data.err_count) + "</p>";
  page += "<p><a href='/json'>/json</a> | <a href='/metrics'>/metrics</a></p>";
  page += "</body></html>";
  server.send(200, "text/html; charset=utf-8", page);
}

void handleJSON() {
  StaticJsonDocument<512> doc;
  doc["ip"] = eth_connected ? eth_ip.toString() : "desconectado";
  doc["uptime_ms"] = millis();
  doc["ok_count"] = data.ok_count;
  doc["err_count"] = data.err_count;

  JsonObject m = doc.createNestedObject("metrics");
  m["voltage_V"] = data.voltage;
  m["current_A"] = data.current;
  m["active_power_W"] = data.active_power;
  m["power_factor"] = data.power_factor;
  m["frequency_Hz"] = data.frequency;
  m["import_active_energy_kWh"] = data.import_active_energy;
  m["last_update_ms"] = data.last_update_ms;

  String out;
  serializeJsonPretty(doc, out);
  server.send(200, "application/json; charset=utf-8", out);
}

void handleMetrics() {
  String txt;
  txt.reserve(512);
  txt += "# HELP voltage_V Tensión de línea (V)\n# TYPE voltage_V gauge\n";
  txt += "voltage_V " + String(isnan(data.voltage)?0:data.voltage, 6) + "\n";
  txt += "# HELP current_A Corriente (A)\n# TYPE current_A gauge\n";
  txt += "current_A " + String(isnan(data.current)?0:data.current, 6) + "\n";
  txt += "# HELP active_power_W Potencia activa (W)\n# TYPE active_power_W gauge\n";
  txt += "active_power_W " + String(isnan(data.active_power)?0:data.active_power, 6) + "\n";
  txt += "# HELP power_factor Factor de potencia\n# TYPE power_factor gauge\n";
  txt += "power_factor " + String(isnan(data.power_factor)?0:data.power_factor, 6) + "\n";
  txt += "# HELP frequency_Hz Frecuencia (Hz)\n# TYPE frequency_Hz gauge\n";
  txt += "frequency_Hz " + String(isnan(data.frequency)?0:data.frequency, 6) + "\n";
  txt += "# HELP import_active_energy_kWh Energía importada total (kWh)\n# TYPE import_active_energy_kWh counter\n";
  txt += "import_active_energy_kWh " + String(isnan(data.import_active_energy)?0:data.import_active_energy, 6) + "\n";
  server.send(200, "text/plain; charset=utf-8", txt);
}

void handleNotFound() {
  server.send(404, "text/plain; charset=utf-8", "Not found");
}

// -------------------- Ethernet Events --------------------
void WiFiEvent(WiFiEvent_t event) {
  switch (event) {
    case ARDUINO_EVENT_ETH_START:
      ETH.setHostname("modbus-logger");
      break;
    case ARDUINO_EVENT_ETH_CONNECTED:
      break;
    case ARDUINO_EVENT_ETH_GOT_IP:
      eth_connected = true;
      eth_ip = ETH.localIP();
      break;
    case ARDUINO_EVENT_ETH_DISCONNECTED:
      eth_connected = false;
      break;
    case ARDUINO_EVENT_ETH_STOP:
      eth_connected = false;
      break;
    default:
      break;
  }
}

// -------------------- Setup --------------------
void setup() {
  pinMode(PIN_RS485_REDE, OUTPUT);
  digitalWrite(PIN_RS485_REDE, LOW); // RX por defecto
  Serial.begin(115200);
  delay(200);

  Serial.println();
  Serial.println("=== modbus-energy-logger-ethernet (ESP32-Ethernet-Kit + LAN8720 + MAX3485) ===");

  // UART2 para RS485
  RS485.begin(MODBUS_BAUD, SERIAL_8N1, PIN_RS485_RX, PIN_RS485_TX);

  // ModbusMaster: enlazar a Serial2 y configurar callbacks DE/RE
  modbus.begin(MODBUS_BAUD, RS485);
  modbus.preTransmission(preTransmission);
  modbus.postTransmission(postTransmission);
  modbus.setTimeout(MODBUS_TIMEOUT_MS);

  // Ethernet (LAN8720)
  WiFi.onEvent(WiFiEvent);
  if (!ETH.begin(ETH_PHY_ADDR, ETH_POWER_PIN, ETH_MDC_PIN, ETH_MDIO_PIN, ETH_PHY_TYPE, ETH_CLK_MODE)) {
    Serial.println("[ETH] Fallo al iniciar ETH");
  } else {
    Serial.println("[ETH] Iniciando Ethernet (DHCP)...");
  }

  // Servidor HTTP
  server.on("/", handleRoot);
  server.on("/json", handleJSON);
  server.on("/metrics", handleMetrics);
  server.onNotFound(handleNotFound);
  server.begin();
  Serial.println("[HTTP] Servidor iniciado en :80");
}

// -------------------- Bucle principal --------------------
uint32_t lastPoll = 0;

void loop() {
  server.handleClient();

  uint32_t now = millis();
  if (now - lastPoll >= POLL_INTERVAL_MS) {
    lastPoll = now;

    // Secuencia de lecturas Modbus (Input Registers como float 32b)
    float v;
    if (readFloatInputRegister(REG_VOLTAGE, v)) {
      data.voltage = v;
      data.ok_count++;
    } else {
      data.err_count++;
    }

    float i;
    if (readFloatInputRegister(REG_CURRENT, i)) {
      data.current = i;
      data.ok_count++;
    } else {
      data.err_count++;
    }

    float p;
    if (readFloatInputRegister(REG_ACTIVE_POWER, p)) {
      data.active_power = p;
      data.ok_count++;
    } else {
      data.err_count++;
    }

    float pf;
    if (readFloatInputRegister(REG_POWER_FACTOR, pf)) {
      data.power_factor = pf;
      data.ok_count++;
    } else {
      data.err_count++;
    }

    float f;
    if (readFloatInputRegister(REG_FREQUENCY, f)) {
      data.frequency = f;
      data.ok_count++;
    } else {
      data.err_count++;
    }

    float eimp;
    if (readFloatInputRegister(REG_IMPORT_ACTIVE_ENERGY, eimp)) {
      data.import_active_energy = eimp;
      data.ok_count++;
    } else {
      data.err_count++;
    }

    data.last_update_ms = now;

    // Trazas serie
    Serial.printf("[POLL] V=%.3f V, I=%.3f A, P=%.3f W, PF=%.3f, f=%.3f Hz, Eimp=%.3f kWh | OK=%lu ERR=%lu\n",
      data.voltage, data.current, data.active_power, data.power_factor, data.frequency, data.import_active_energy,
      (unsigned long)data.ok_count, (unsigned long)data.err_count);
  }
}

Breve explicación de partes clave:
– Inicialización ETH con LAN8720 (ETH.begin con parámetros: PHY_ADDR=0, pines MDC=23/MDIO=18, PHY=LAN8720, clock por GPIO17). Se usa DHCP. ETH.setHostname configura el hostname anunciado.
– Modbus RTU maestro sobre UART2 (GPIO4/5) y control half-duplex por GPIO15 (DE/RE). ModbusMaster usa callbacks preTransmission/postTransmission para manejar el transceptor MAX3485.
– Lecturas de registros de entrada (Input Registers) de 32 bits flotantes: se leen 2 registros consecutivos y se reempaquetan a float, con opción de swap de palabras (FLOAT_WORD_SWAP).
– WebServer nativo expone HTML, JSON y métricas de texto para facilitar integración.

Nota sobre direcciones de registros: los offsets son los usados por muchas familias SDM*. Ajuste si su medidor difiere.

Compilación, flasheo y ejecución

Preparar el proyecto

1) Crear directorio de trabajo y plantillas:

mkdir modbus-energy-logger-ethernet
cd modbus-energy-logger-ethernet
pio project init --board esp32-ethernet-kit

2) Sustituir el contenido de platformio.ini por el bloque mostrado en este documento.

3) Crear carpeta src y archivo:
– src/main.cpp con el código provisto.

Construcción

  • Instalar dependencias y compilar:
pio pkg install
pio run

Flasheo (upload)

  • Conectar el ESP32-Ethernet-Kit por micro-USB.
  • Identificar el puerto (ej.: COM5 en Windows, /dev/ttyUSB0 en Linux, /dev/tty.SLAB_USBtoUART en macOS).
  • Subir firmware:
# PlatformIO detecta el puerto automáticamente en la mayoría de casos
pio run -t upload
# Si necesitas fijar el puerto:
pio run -t upload --upload-port COM5
# o
pio run -t upload --upload-port /dev/ttyUSB0

Monitor serie

  • Abrir monitor a 115200 baudios:
pio device monitor -b 115200
  • Deberías ver mensajes tipo:
  • [ETH] Iniciando Ethernet (DHCP)…
  • evento ETH_GOT_IP con la IP asignada
  • [HTTP] Servidor iniciado en :80
  • [POLL] con valores de V, I, P, etc.

Pruebas rápidas de red

  • Descubrir la IP si no la ves en serie (desde tu router/DHCP) o usa ping:
  • Windows: ping
  • Linux/macOS: ping -c 4
  • Obtener JSON:
curl http://<IP_DEL_ESP32>/json
  • Ver HTML:
  • Navegador: http:///

Opcional: IP estática

Si tu red no usa DHCP, en setup() después de ETH.begin() puedes fijar IP:

// Sustituye por tu red
IPAddress ip(192,168,1,50);
IPAddress gw(192,168,1,1);
IPAddress mask(255,255,255,0);
IPAddress dns1(8,8,8,8), dns2(1,1,1,1);
ETH.config(ip, gw, mask, dns1, dns2);

Validación paso a paso

1) Verificación de arranque:
– En el monitor serie, confirmar:
– “Iniciando Ethernet (DHCP)…”
– Evento ETH_GOT_IP con una dirección válida (por ejemplo 192.168.1.x).
– “Servidor iniciado en :80”.

2) Link Ethernet:
– LED del puerto Ethernet activo (link/actividad).
– Desde tu PC en la misma red, ping a la IP del ESP32.
– Respuesta < 2 ms en red local típica.

3) Acceso HTTP:
– Abrir http:/// en el navegador y ver una tabla con las métricas energéticas.
– Actualizar manualmente para observar cambios (cada ~1 s se realiza un sondeo).

4) JSON y métricas:
– Ejecutar:
– curl http:///json
– curl http:///metrics
– Confirmar formato y que los valores son coherentes.

5) Validación Modbus:
– Observar en el terminal: líneas [POLL] con lecturas numéricas y contadores OK/ERR.
– Si tienes display en el medidor, comparar:
– Tensión (V)
– Corriente (A)
– Potencia activa (W)
– Frecuencia (Hz)
– PF
– Energía importada (kWh)
– Tolerancias: pequeñas diferencias por muestreo y resolución del medidor.

6) Integridad del bus RS485:
– Si el MAX3485 tiene LEDs, ver actividad en TX/RX durante los sondeos.
– A/B cableados correctamente: valores estables, ERR no aumenta continuamente.

7) Estabilidad:
– Dejar funcionando 10–15 minutos.
– OK_count sube con cada lectura; ERR_count se mantiene cerca de 0.
– Página HTML/JSON responden consistentemente.

Troubleshooting

1) No se obtiene IP (ETH_GOT_IP nunca aparece)
– Causas:
– Cable RJ45 defectuoso o puerto de switch sin enlace.
– DHCP deshabilitado o saturado.
– Pines RMII mal configurados en el código (no usar otros valores en ETH.begin).
– Soluciones:
– Cambiar cable/puerto.
– Probar con IP estática usando ETH.config().
– Verificar que ETH_MDC=23, ETH_MDIO=18, ETH_CLK_MODE=ETH_CLOCK_GPIO17_OUT, PHY_ADDR=0.

2) ETH arranca pero no hay tráfico
– Causas:
– Conmutador en VLAN aislada.
– Duplicación de IP (si IP estática).
– Soluciones:
– Validar segmentación de red.
– Cambiar IP estática o volver a DHCP.

3) Lecturas Modbus fallan (ERR_count sube rápidamente)
– Causas:
– Inversión de líneas RS485 A/B.
– Falta de terminación 120 Ω en extremos.
– No hay masa común entre sistemas.
– Baud/paridad distintos a los del medidor.
– Soluciones:
– Invertir A/B y repetir.
– Añadir/retirar terminación según topología.
– Asegurar GND común.
– Ajustar MODBUS_BAUD y formato (SERIAL_8N1; si tu medidor usa 8E1 o 8O1, cambia en RS485.begin()).

4) Valores incoherentes (p. ej., 65k V o NaN)
– Causa: Endianness de las palabras Modbus distinta.
– Solución: Cambiar FLOAT_WORD_SWAP (true/false) y volver a flashear.

5) El ESP32 no entra en modo programación (upload falla)
– Causas:
– Drivers CP210x/CH34x no instalados (Windows/macOS).
– Cable USB solo carga.
– Conflicto de puerto.
– Soluciones:
– Reinstalar drivers.
– Usar cable de datos y otro puerto USB.
– Especificar –upload-port correctamente.

6) Parpadeo de errores al iniciar (bootloop o mensajes “brownout”)
– Causas:
– Alimentación insuficiente por USB/puerto.
– Cortocircuito en conexiones al MAX3485.
– Soluciones:
– Alimentar con un puerto USB de 1 A mínimo o fuente externa 5 V.
– Revisar cableado y soldaduras.

7) Bloqueos intermitentes en Modbus con cable largo
– Causas:
– Ruido EMI, impedancia de línea, topología en estrella.
– Soluciones:
– Topología en bus, terminaciones en extremos, resistencias de polarización (bias) en A/B si el maestro no las provee, usar cable trenzado y blindado.

8) GPIO15 causa problemas al boot
– Causa:
– GPIO strap sensible; forzar niveles al arranque puede influir.
– Solución:
– Mantener GPIO15 en LOW por defecto (como hace el código). Evitar hardware externo que lo eleve en boot.

Mejoras/variantes

  • Push a InfluxDB (v2) o VictoriaMetrics:
  • Enviar con HTTP cada N segundos, formateando line protocol:
    • Ejemplo de línea: energy,host=esp32 voltage=230.1,current=1.23,power=283.0 1730800000000
  • Añadir una tarea que haga POST a http://influxdb:8086/api/v2/write?org=…&bucket=…&precision=ns con el token en cabecera.

  • MQTT sobre Ethernet:

  • Publicar en tópicos: telemetry/voltage, telemetry/current, etc., usando una librería MQTT (p. ej., PubSubClient) con ETH.

  • NTP y sellado temporal:

  • Sincronizar hora vía NTP (configTime) para registrar timestamps reales y mostrarlos en /json.

  • Configuración en runtime:

  • Exponer UI para cambiar MODBUS_SLAVE_ID, baud, intervalos y endianness desde el navegador y guardar en NVS.

  • Registro persistente:

  • Grabar CSV en SPIFFS/LittleFS con rotación diaria. Exponer descarga por HTTP.

  • Multi-esclavo:

  • Sondear varios medidores en el mismo bus ajustando MODBUS_SLAVE_ID para cada ciclo (añadir lista de esclavos).

  • Seguridad básica:

  • Autenticación HTTP simple para /json y /metrics si se expone fuera de la LAN.

  • IP estática por fallback:

  • Intentar DHCP y, si falla, aplicar una IP estática de respaldo.

Checklist de verificación

  • [ ] He instalado PlatformIO Core 6.1.14 y Python 3.11.6 (o compatibles) y verifiqué sus versiones.
  • [ ] Mi sistema reconoce el USB-UART (CP210x/CH34x) y sé cuál es el puerto.
  • [ ] He creado el proyecto con board = esp32-ethernet-kit y usé el platformio.ini provisto con versiones fijas.
  • [ ] He cableado el MAX3485 a los pines GPIO4 (TX), GPIO5 (RX) y GPIO15 (DE/RE), con 3V3 y GND.
  • [ ] He conectado RS485 A↔A y B↔B entre MAX3485 y medidor, con terminación 120 Ω en los extremos si procede.
  • [ ] El cable Ethernet RJ45 está conectado, y el switch/router ofrece DHCP o he configurado IP estática.
  • [ ] El firmware compila, se flashea sin errores y el monitor serie muestra ETH_GOT_IP con una IP válida.
  • [ ] Puedo abrir http:/// y ver la tabla con las métricas, y /json y /metrics responden correctamente.
  • [ ] Los valores leídos son coherentes con el display del medidor, con errores de lectura mínimos (ERR_count ~ 0).
  • [ ] Si hay incoherencias en floats, ajusté FLOAT_WORD_SWAP y confirmé la corrección.

Con todo lo anterior, has implementado un “modbus-energy-logger-ethernet” robusto y reproducible usando exactamente el “ESP32-Ethernet-Kit + LAN8720 + MAX3485 RS485” y una toolchain con versiones fijas sobre PlatformIO.

Encuentra este producto y/o libros sobre este tema en Amazon

Ir a Amazon

Como afiliado de Amazon, gano con las compras que cumplan los requisitos. Si compras a través de este enlace, ayudas a mantener este proyecto.

Quiz rápido

Pregunta 1: ¿Cuál es la versión de PlatformIO Core que se debe utilizar?




Pregunta 2: ¿Qué sistema operativo no está soportado según el artículo?




Pregunta 3: ¿Cuál es la versión de Python requerida?




Pregunta 4: ¿Qué driver se recomienda para Windows?




Pregunta 5: ¿Qué librería se menciona para el proyecto?




Pregunta 6: ¿Cuál es la plataforma de hardware mencionada en el artículo?




Pregunta 7: ¿Qué comando se utiliza para verificar la versión de Python en Linux/macOS?




Pregunta 8: ¿Qué tipo de red se recomienda para las primeras pruebas?




Pregunta 9: ¿Cuál es la versión de OpenOCD-ESP32 mencionada?




Pregunta 10: ¿Qué tipo de driver USB-UART se menciona para el ESP32-Ethernet-Kit?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Ingeniero Superior en Electrónica de Telecomunicaciones e Ingeniero en Informática (titulaciones oficiales en España).

Sígueme: