Un proyecto ESP32 PlatformIO MQTT puede ser mucho más que un único sketch con credenciales escritas a mano en el código. En esta guía configurarás un proyecto de PlatformIO con un entorno de desarrollo que apunta a broker.connectmq.com y un entorno de producción con TLS y credenciales privadas, con los secretos fuera de Git y las versiones de librerías fijadas para obtener compilaciones reproducibles.
Por qué PlatformIO para firmware MQTT
El Arduino IDE sirve para un primer sketch. En cuanto un proyecto tiene más de un archivo, más de un destino o más de un desarrollador, PlatformIO compensa:
- Compilaciones reproducibles. Las versiones de la plataforma y de las librerías viven en
platformio.ini, así que un compañero o un servidor de CI compila exactamente lo mismo que tú. - Múltiples entornos. Un mismo proyecto puede generar firmware para un broker de pruebas y otro de producción, o para placas distintas, a partir del mismo código.
- Estructura de proyecto real. Cabeceras en
include/, código repartido en varios archivos ensrc/, módulos reutilizables enlib/y pruebas entest/. - Compilación desde la línea de comandos.
pio runfunciona igual en VS Code, en una terminal y en un pipeline de CI.
Estructura del proyecto
Crea un proyecto nuevo para un ESP32 DevKit con el framework Arduino y dale esta forma:
esp32-mqtt/
platformio.ini
secrets.ini (not committed)
secrets.example.ini (committed template)
.gitignore
include/
config.h
src/
main.cpp
mqtt_link.cpp
mqtt_link.h
.gitignore mantiene los secretos locales y los artefactos de compilación fuera del repositorio:
.pio/
secrets.ini
El platformio.ini con entornos de desarrollo y producción
[platformio]
default_envs = dev
extra_configs = secrets.ini
[env]
platform = espressif32
board = esp32dev
framework = arduino
monitor_speed = 115200
monitor_filters = esp32_exception_decoder
lib_deps =
knolleary/PubSubClient @ ^2.8
bblanchon/ArduinoJson @ ^7.0.0
build_flags =
-D WIFI_SSID=\"${secrets.wifi_ssid}\"
-D WIFI_PASS=\"${secrets.wifi_pass}\"
-D FW_VERSION=\"1.0.0\"
[env:dev]
build_flags =
${env.build_flags}
-D MQTT_HOST=\"broker.connectmq.com\"
-D MQTT_PORT=1883
-D MQTT_USE_TLS=0
-D TOPIC_PREFIX=\"connectmq/demo/6d1f3c\"
-D CORE_DEBUG_LEVEL=3
[env:prod]
build_flags =
${env.build_flags}
-D MQTT_HOST=\"broker.connectmq.com\"
-D MQTT_PORT=8883
-D MQTT_USE_TLS=1
-D MQTT_CLIENT_ID=\"${secrets.mqtt_client_id}\"
-D MQTT_USER=\"${secrets.mqtt_user}\"
-D MQTT_PASS=\"${secrets.mqtt_pass}\"
-D TOPIC_PREFIX=\"factory1/line1/sensor01\"
-D CORE_DEBUG_LEVEL=1
La sección compartida [env] contiene todo lo común. Cada entorno con nombre amplía build_flags con sus propios ajustes de broker. extra_configs = secrets.ini fusiona un segundo archivo con la configuración, de modo que ${secrets.wifi_ssid} se resuelve con un valor que nunca entra en el control de versiones.
El archivo secrets.ini:
[secrets]
wifi_ssid = YOUR_WIFI
wifi_pass = YOUR_WIFI_PASSWORD
mqtt_client_id = YOUR_CLIENT_ID
mqtt_user = YOUR_USERNAME
mqtt_pass = YOUR_PASSWORD
Sube al repositorio secrets.example.ini con las mismas claves y valores de ejemplo para que los nuevos colaboradores sepan qué rellenar. Los valores de producción salen del portal de ConnectMQ, donde cada broker recibe un client ID, un usuario y una contraseña generados.
En CI puedes prescindir del archivo y leer variables de entorno con ${sysenv.MQTT_PASS}. Ten presente que los build flags acaban como cadenas dentro del binario, así que cualquiera con el archivo de firmware puede extraerlos. Para dispositivos que se distribuyen a terceros, es preferible usar credenciales por dispositivo aprovisionadas en tiempo de ejecución, por ejemplo con el portal WiFiManager MQTT para ESP32.
config.h: un único lugar para los valores por defecto
La cabecera da a cada ajuste un valor por defecto seguro y hace fallar la compilación si falta algo esencial:
#pragma once
#ifndef MQTT_HOST
#define MQTT_HOST "broker.connectmq.com"
#endif
#ifndef MQTT_PORT
#define MQTT_PORT 1883
#endif
#ifndef MQTT_USE_TLS
#define MQTT_USE_TLS 0
#endif
#ifndef TOPIC_PREFIX
#error "TOPIC_PREFIX must be set in platformio.ini"
#endif
#if MQTT_USE_TLS && !defined(MQTT_PASS)
#error "Production builds need MQTT credentials from secrets.ini"
#endif
#ifndef FW_VERSION
#define FW_VERSION "dev"
#endif
Un #error es muchísimo mejor que un firmware que compila, se distribuye y luego se conecta en silencio al sitio equivocado.
El módulo MQTT
Mantener la lógica MQTT en su propio archivo hace que main.cpp sea corto y te permite reutilizar el módulo entre proyectos.
src/mqtt_link.h:
#pragma once
#include <Arduino.h>
namespace mqtt_link {
void begin();
void loop();
bool publish(const char* subtopic, const char* payload, bool retain = false);
bool connected();
}
src/mqtt_link.cpp:
#include "mqtt_link.h"
#include "config.h"
#include <WiFi.h>
#include <PubSubClient.h>
#if MQTT_USE_TLS
#include <WiFiClientSecure.h>
#include "isrg_root_x1.h" // const char ISRG_ROOT_X1[] = R"EOF(...PEM...)EOF";
#endif
namespace {
#if MQTT_USE_TLS
WiFiClientSecure net;
#else
WiFiClient net;
#endif
PubSubClient client(net);
String clientId;
String statusTopic;
unsigned long nextAttempt = 0;
unsigned long backoffMs = 1000;
void onMessage(char* topic, byte* payload, unsigned int length) {
Serial.printf("[mqtt] %s: %.*s\n", topic, (int)length, (const char*)payload);
}
void tryConnect() {
bool ok;
#if MQTT_USE_TLS
ok = client.connect(MQTT_CLIENT_ID, MQTT_USER, MQTT_PASS,
statusTopic.c_str(), 1, true, "offline");
#else
ok = client.connect(clientId.c_str(), statusTopic.c_str(), 1, true, "offline");
#endif
if (ok) {
client.publish(statusTopic.c_str(), "online", true);
client.subscribe((String(TOPIC_PREFIX) + "/cmd").c_str());
backoffMs = 1000;
Serial.println("[mqtt] connected");
} else {
Serial.printf("[mqtt] failed, state=%d\n", client.state());
nextAttempt = millis() + backoffMs;
backoffMs = min(backoffMs * 2, 60000UL);
}
}
} // namespace
namespace mqtt_link {
void begin() {
#if MQTT_USE_TLS
net.setCACert(ISRG_ROOT_X1);
clientId = MQTT_CLIENT_ID;
#else
clientId = "esp32-pio-" + String((uint32_t)ESP.getEfuseMac(), HEX);
#endif
statusTopic = String(TOPIC_PREFIX) + "/status";
client.setServer(MQTT_HOST, MQTT_PORT);
client.setBufferSize(512);
client.setSocketTimeout(5);
client.setCallback(onMessage);
}
void loop() {
if (WiFi.status() != WL_CONNECTED) return;
if (client.connected()) {
client.loop();
} else if (millis() >= nextAttempt) {
tryConnect();
}
}
bool publish(const char* subtopic, const char* payload, bool retain) {
String topic = String(TOPIC_PREFIX) + "/" + subtopic;
return client.publish(topic.c_str(), payload, retain);
}
bool connected() { return client.connected(); }
} // namespace mqtt_link
Crea include/isrg_root_x1.h con el certificado ISRG Root X1 de Let's Encrypt como cadena PEM. Solo se compila en las versiones de producción.
src/main.cpp:
#include <Arduino.h>
#include <WiFi.h>
#include <ArduinoJson.h>
#include "config.h"
#include "mqtt_link.h"
unsigned long lastPublish = 0;
void setup() {
Serial.begin(115200);
Serial.printf("Firmware %s, broker %s:%d\n", FW_VERSION, MQTT_HOST, MQTT_PORT);
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
WiFi.begin(WIFI_SSID, WIFI_PASS);
mqtt_link::begin();
}
void loop() {
mqtt_link::loop();
if (mqtt_link::connected() && millis() - lastPublish > 10000) {
lastPublish = millis();
JsonDocument doc;
doc["fw"] = FW_VERSION;
doc["uptime"] = millis() / 1000;
doc["rssi"] = WiFi.RSSI();
char buf[128];
serializeJson(doc, buf, sizeof(buf));
mqtt_link::publish("telemetry", buf);
}
}
Compilar, grabar y monitorizar
# compilación de desarrollo contra el área pública de pruebas
pio run -e dev -t upload
pio device monitor -e dev
# compilación de producción con TLS y credenciales del portal
pio run -e prod -t upload
# compila todos los entornos, como haría la CI
pio run
Mientras se ejecuta la versión de desarrollo, observa sus topics desde tu ordenador:
mosquitto_sub -h broker.connectmq.com -p 1883 -i pc-6d1f3c -t 'connectmq/demo/6d1f3c/#' -v
Ajustes de cada entorno de un vistazo
| Ajuste | dev | prod |
|---|---|---|
| Broker | Área pública de broker.connectmq.com | Tu broker privado de ConnectMQ |
| Puerto y transporte | 1883, TCP sin cifrar | 8883, TLS con ISRG Root X1 |
| Client ID | Derivado de la MAC | Client ID emitido por el portal |
| Credenciales | Ninguna | Usuario y contraseña de secrets.ini |
| Prefijo de topic | connectmq/demo/<random> |
Tu propia jerarquía, como una ruta ISA-95 |
| Nivel de log | Info | Solo errores |
Por qué compensa esta estructura
Dividir el código así no es una cuestión de orden por el orden. Cada pieza cumple una función que marca una diferencia real en cuanto el proyecto sale de tu mesa.
Los entornos codifican la intención. Nadie tiene que acordarse de cambiar un puerto, sustituir WiFiClient por WiFiClientSecure y pegar credenciales antes de una versión. Elegir prod hace todo eso de forma coherente, y las comprobaciones #error de config.h detienen una compilación a medio configurar antes de que llegue a un dispositivo. La misma idea se amplía con naturalidad: añade un entorno staging que apunte a un segundo broker privado, o un entorno por tipo de placa con una línea board = distinta.
El módulo MQTT oculta el transporte. main.cpp nunca sabe si se comunica por TCP sin cifrar o por TLS, ni qué client ID se está usando. Cuando más adelante sustituyas PubSubClient por un cliente asíncrono, o muevas los topics a una nueva jerarquía, el cambio se queda en un solo archivo. El módulo también se encarga del backoff de reconexión, así que cada proyecto que lo reutiliza obtiene reconexión sin bloqueos de serie.
El topic de estado y el Last Will vienen incluidos. Como tryConnect() siempre registra un will retenido offline y publica online tras conectarse, cada dispositivo creado a partir de esta plantilla informa de su disponibilidad sin código adicional.
Las dependencias fijadas dan sentido a la CI. Un pipeline que ejecuta pio run en cada commit solo demuestra algo si compila con las mismas versiones de librerías que usan los desarrolladores. Con rangos con acento circunflejo como ^2.8 recibes actualizaciones de corrección de errores, pero no versiones mayores que rompan la compatibilidad.
Si quieres comprobaciones automáticas más allá de compilar, el ejecutor de pruebas unitarias de PlatformIO puede lanzar las pruebas en la propia placa o en el host con un entorno native. La lógica pura, como la construcción de topics o el formato del payload, es fácil de probar de forma nativa una vez que vive en su propio módulo.
Errores habituales con PlatformIO
- Macros de cadena sin comillas escapadas.
-D MQTT_HOST=broker.connectmq.comdefine un token suelto, no una cadena, y no compila. Usa\"...\"como se muestra. - Sobrescribir los flags en lugar de ampliarlos. Escribir
build_flags =en un entorno sin${env.build_flags}descarta los flags compartidos, incluidas las credenciales Wi-Fi. - Librerías sin versión fijada. Un nombre de librería sin versión descarga la más reciente, y una actualización mayor como ArduinoJson 6 a 7 puede romper tu compilación de la noche a la mañana. Fija la versión con
@ ^x.y.z. - Falta
secrets.ini. Las referencias a una sección no definida fallan de formas confusas. El archivo de ejemplo del repositorio lo evita. - Compilaciones obsoletas tras cambiar flags. PlatformIO normalmente recompila cuando cambian los flags, pero si el comportamiento parece incorrecto, ejecuta
pio run -t clean. - Decodificar los fallos. Mantén
monitor_filters = esp32_exception_decoderpara que los backtraces muestren archivo y línea, algo muy valioso ante problemas como los reinicios del task watchdog del ESP32.
Próximos pasos
Con una estructura limpia, añadir funciones es sencillo. Lleva el manejo de JSON a su propio módulo siguiendo la guía de ArduinoJson y MQTT para ESP32, añade la entrega remota de firmware con las actualizaciones OTA del ESP32 por MQTT y consulta el tutorial de IoT con ESP32 para ver el proyecto completo del dispositivo al dashboard. Para entender por qué los brokers de dev y prod se comportan de forma distinta, lee sobre el servidor MQTT público gratuito y sus límites: está pensado para pruebas y prototipos, no para datos de producción.
Publica tu firmware PlatformIO con ConnectMQ
El entorno dev funciona de inmediato con el área pública de pruebas. Para el entorno prod, crea un broker privado en ConnectMQ, un broker MQTT gestionado con MQTT 3.1.1 y 5.0, TLS en el puerto 8883 y un certificado de Let's Encrypt. Regístrate en el plan gratuito Serverless en app.connectmq.com, sin tarjeta de crédito, copia las credenciales generadas en secrets.ini y ejecuta pio run -e prod -t upload.
Preguntas frecuentes
¿Cómo mantengo las contraseñas MQTT fuera de Git en PlatformIO?
Ponlas en un archivo secrets.ini que figure en .gitignore y se fusione con extra_configs, o léelas de variables de entorno con ${sysenv.NAME} en CI.
¿Puede un proyecto de PlatformIO compilar para varios brokers?
Sí. Define un entorno por broker o etapa de despliegue, cada uno ampliando los build_flags compartidos con su propio host, puerto y credenciales, y selecciónalo con pio run -e <name>.
¿Son seguras las credenciales en build flags dentro del firmware?
Se compilan en el binario como cadenas en texto plano, así que cualquiera con el archivo puede extraerlas. Para dispositivos que entregas a terceros, aprovisiona las credenciales en tiempo de ejecución y guárdalas en NVS.
¿Qué librerías debo usar para MQTT en PlatformIO?
PubSubClient es el cliente síncrono más utilizado, y AsyncMqttClient es una alternativa basada en eventos. Añade cualquiera de ellos a lib_deps con una versión fijada.



