Un timestamp NTP en los mensajes MQTT de un ESP32 registra cuándo se tomó realmente una lectura, no cuándo llegó por casualidad al broker. En este tutorial sincronizarás el reloj del ESP32 por SNTP, formatearás marcas de tiempo UTC en ISO 8601 y en milisegundos epoch, y las publicarás en JSON en broker.connectmq.com, incluidos los casos límite que rompen las implementaciones ingenuas.
Por qué los mensajes MQTT necesitan su propia marca de tiempo
MQTT no incluye una marca de tiempo en el paquete. Un suscriptor sabe cuándo llegó un mensaje, pero eso puede diferir mucho del momento en que se leyó el sensor:
- El dispositivo almacenó lecturas durante un corte de Wi-Fi y las publica de golpe más tarde.
- Una sesión persistente entregó mensajes QoS 1 en cola a un suscriptor que estaba desconectado.
- Un retained message se entrega a un nuevo suscriptor horas después de publicarse.
- La latencia de red y los reintentos añaden una variación que importa en eventos rápidos.
Si tu dashboard o tu base de datos usa la hora de llegada, los cuatro casos producen gráficas incorrectas. La solución es sencilla: deja que el dispositivo marque cada lectura en el momento de la captura, usando un reloj sincronizado con UTC. El ESP32 tiene un contador de tiempo real pero, en la mayoría de las placas, ningún reloj con batería, así que tras cada arranque empieza el 1 de enero de 1970 hasta que NTP lo corrige.
Elegir el formato de la marca de tiempo
| Formato | Ejemplo | Tamaño | Ideal para |
|---|---|---|---|
| ISO 8601 UTC con ms | 2026-10-16T09:41:07.382Z |
24 caracteres | Logs legibles, la mayoría de consumidores JSON |
| Segundos epoch | 1792143667 |
10 dígitos | Payloads compactos, telemetría poco fina |
| Milisegundos epoch | 1792143667382 |
13 dígitos | Bases de datos de series temporales, orden de eventos |
| Hora local con desfase | 2026-10-16T11:41:07+02:00 |
25 caracteres | Pantallas ligadas a una única ubicación |
Usa UTC en la transmisión. Convierte a hora local en el dashboard, donde se conoce la zona horaria del usuario. Las marcas de tiempo locales en los dispositivos provocan horas duplicadas o perdidas dos veces al año con el cambio de horario. El sketch siguiente publica tanto ISO 8601 como milisegundos epoch para que elijas aguas abajo.
Requisitos previos
- Una placa ESP32 con el core de Arduino 3.x (el core 2.x también sirve para este sketch).
- PubSubClient de Nick O'Leary.
- El puerto UDP 123 saliente permitido en tu red para NTP.
mosquitto_subpara las pruebas.
El sketch de timestamp NTP en MQTT para ESP32
#include <WiFi.h>
#include <PubSubClient.h>
#include <time.h>
#include <sys/time.h>
#include "esp_sntp.h"
const char* WIFI_SSID = "YOUR_WIFI_SSID";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char* MQTT_HOST = "broker.connectmq.com";
const uint16_t MQTT_PORT = 1883;
const char* TOPIC = "connectmq/demo/ntp8r2c/telemetry"; // haz que el id sea único
const time_t VALID_EPOCH = 1700000000; // cualquier valor anterior significa "aún sin sincronizar"
WiFiClient net;
PubSubClient mqtt(net);
String clientId;
volatile bool timeSynced = false;
unsigned long lastPublish = 0;
void onTimeSync(struct timeval* tv) {
timeSynced = true;
Serial.printf("NTP sync at epoch %ld\n", (long)tv->tv_sec);
}
// Escribe p. ej. 2026-10-16T09:41:07.382Z; devuelve false si el reloj no es válido
bool isoTimestamp(char* buf, size_t len, uint64_t* epochMs) {
struct timeval tv;
gettimeofday(&tv, nullptr);
if (tv.tv_sec < VALID_EPOCH) return false;
struct tm utc;
gmtime_r(&tv.tv_sec, &utc);
size_t n = strftime(buf, len, "%Y-%m-%dT%H:%M:%S", &utc);
snprintf(buf + n, len - n, ".%03ldZ", (long)(tv.tv_usec / 1000));
*epochMs = (uint64_t)tv.tv_sec * 1000ULL + tv.tv_usec / 1000;
return true;
}
void connectWifi() {
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED) delay(300);
Serial.printf("Wi-Fi connected, IP %s\n", WiFi.localIP().toString().c_str());
}
void startNtp() {
sntp_set_time_sync_notification_cb(onTimeSync);
configTime(0, 0, "pool.ntp.org", "time.cloudflare.com", "time.google.com");
// Espera hasta 15 s la primera sincronización, pero nunca bloquea indefinidamente
struct tm tmp;
if (getLocalTime(&tmp, 15000)) {
Serial.println("Clock is valid");
} else {
Serial.println("NTP not reachable yet, will publish with timeSynced=false");
}
}
void connectMqtt() {
while (!mqtt.connected()) {
if (mqtt.connect(clientId.c_str())) {
Serial.println("MQTT connected");
} else {
Serial.printf("MQTT failed, state %d\n", mqtt.state());
delay(5000);
}
}
}
void setup() {
Serial.begin(115200);
clientId = "esp32-ntp-" + String((uint32_t)ESP.getEfuseMac(), HEX);
connectWifi();
startNtp();
mqtt.setServer(MQTT_HOST, MQTT_PORT);
mqtt.setBufferSize(512);
connectMqtt();
}
void loop() {
if (WiFi.status() != WL_CONNECTED) connectWifi();
if (!mqtt.connected()) connectMqtt();
mqtt.loop();
if (millis() - lastPublish < 5000) return;
lastPublish = millis();
// 1. Captura la lectura y su marca de tiempo a la vez
float temperature = 21.5f + (esp_random() % 300) / 100.0f;
char iso[32];
uint64_t epochMs = 0;
bool valid = isoTimestamp(iso, sizeof(iso), &epochMs);
// 2. Construye el payload
char payload[200];
if (valid) {
snprintf(payload, sizeof(payload),
"{\"ts\":\"%s\",\"tsMs\":%llu,\"timeSynced\":true,\"temperature\":%.2f}",
iso, (unsigned long long)epochMs, temperature);
} else {
snprintf(payload, sizeof(payload),
"{\"uptimeMs\":%lu,\"timeSynced\":false,\"temperature\":%.2f}",
millis(), temperature);
}
// 3. Publica
mqtt.publish(TOPIC, payload);
Serial.println(payload);
}
Suscríbete desde tu equipo para ver el resultado:
mosquitto_sub -h broker.connectmq.com -p 1883 -i ts-viewer-8r2c -t "connectmq/demo/ntp8r2c/#" -v
Cada línea tiene este aspecto: {"ts":"2026-10-16T09:41:07.382Z","tsMs":1792143667382,"timeSynced":true,"temperature":22.71}. El área pública del broker está abierta a todos y pensada para pruebas, así que publica solo valores de demostración. Sus límites figuran en la página del servidor MQTT público gratuito.
Cómo funciona SNTP en el ESP32
configTime(0, 0, ...) inicia el cliente SNTP de lwIP con hasta tres servidores y un desfase cero, de modo que el reloj del sistema funciona en UTC. Tras la primera respuesta, el ESP32 mantiene la hora con su temporizador interno y se resincroniza periódicamente, por defecto una vez por hora. Puedes cambiarlo con sntp_set_sync_interval(ms); cualquier valor inferior a unos minutos es innecesario para telemetría y añade carga a los pools NTP públicos.
getLocalTime(&tm, timeoutMs) espera hasta que el año es plausible, lo que resulta una comprobación bloqueante cómoda al arrancar. El callback registrado con sntp_set_time_sync_notification_cb se dispara en cada sincronización correcta, algo útil si quieres registrar la deriva o publicar un estado "reloj correcto". La API de hora del sistema subyacente se describe en la guía de hora del sistema de ESP-IDF.
Deriva del reloj entre sincronizaciones
El cristal principal del ESP32 es lo bastante preciso como para que las sincronizaciones horarias mantengan la deriva muy por debajo de un segundo en la telemetría habitual. El deep sleep es distinto: por defecto, el temporizador RTC funciona con un oscilador RC interno que deriva mucho más. Si tu dispositivo duerme durante periodos largos, resincroniza tras cada despertar o monta un cristal externo de 32 kHz.
Gestionar la ventana sin sincronizar
El error de timestamp más habitual es publicar 1970-01-01T00:00:12Z justo después de arrancar. El sketch lo evita de tres formas:
- Compara el reloj con un epoch reciente conocido (
VALID_EPOCH) antes de formatear. - Cuando el reloj no es válido, envía
uptimeMsytimeSynced:falsepara que el consumidor decida qué hacer. - No bloquea indefinidamente si NTP no responde, así que el dispositivo sigue informando.
Un consumidor puede recurrir a su propia hora de llegada para los mensajes sin sincronizar, o descartarlos. Nunca dejes que el dispositivo se invente una hora.
Mostrar la hora local en el dispositivo
A veces el propio dispositivo necesita la hora local, por ejemplo para activar un relé a las 07:00 o mostrar el reloj en una pequeña pantalla. Mantén el payload en UTC y convierte solo para esos usos locales. La librería C del ESP32 entiende cadenas de zona horaria POSIX, que incluyen las reglas del horario de verano:
// Hora de Europa Central con horario de verano automático
setenv("TZ", "CET-1CEST,M3.5.0,M10.5.0/3", 1);
tzset();
struct tm local;
time_t now = time(nullptr);
localtime_r(&now, &local);
char buf[32];
strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S%z", &local); // p. ej. 2026-10-16T11:41:07+0200
gmtime_r en el sketch principal ignora la variable TZ, así que configurar una zona horaria para la visualización local no cambia las marcas de tiempo UTC que publicas. Busca la cadena POSIX de tu región en lugar de calcular desfases a mano; un desfase fijo de +02:00 es incorrecto durante la mitad del año en casi toda Europa.
Marcas de tiempo con búfer y tareas
Las marcas de tiempo se vuelven imprescindibles en cuanto las lecturas se encolan. Si separas el muestreo y la publicación como en el tutorial de ESP32 FreeRTOS MQTT, guarda epochMs en la estructura encolada en el momento de la captura y formatea el JSON solo en la tarea de publicación. Así, un atasco vaciado tras un corte cae en la posición correcta de cada gráfica. Para payloads más grandes, construir el JSON con una librería es menos propenso a errores que snprintf; consulta ArduinoJson con MQTT en el ESP32.
Los mensajes de tipo evento también se benefician. Un valor retenido de "visto por última vez" con una marca de tiempo real indica a cualquier nuevo suscriptor exactamente cuándo ocurrió algo por última vez; el proyecto con sensor de movimiento PIR usa este patrón en su topic de último movimiento.
Lista de comprobación para resolver problemas
| Síntoma | Causa probable | Solución |
|---|---|---|
| Año 1970 en los payloads | Publicado antes de la primera sincronización | Comprueba VALID_EPOCH antes de formatear |
getLocalTime siempre agota el tiempo |
UDP 123 bloqueado | Usa un servidor NTP en tu LAN o pide a IT que lo permita |
| Marcas de tiempo desfasadas una hora | Zona horaria aplicada dos veces | Mantén el dispositivo en UTC y convierte solo en los dashboards |
Milisegundos siempre .000 |
Formateado desde time() en lugar de gettimeofday() |
Usa struct timeval |
| Hora incorrecta tras el deep sleep | Deriva del RTC durante el sueño | Resincroniza al despertar o añade un cristal de 32 kHz |
%llu imprime basura |
Conversión de tipo incorrecta | Convierte a unsigned long long |
Configuración para producción
Cuando vayas más allá de las pruebas, crea un broker en app.connectmq.com, cambia a WiFiClientSecure en el puerto 8883 con el certificado ISRG Root X1 y conéctate con YOUR_CLIENT_ID, YOUR_USERNAME y YOUR_PASSWORD del portal. Ten en cuenta que una hora sincronizada también es buena higiene para TLS: tanto la comprobación de validez de los certificados como la correlación de logs dependen de un reloj correcto. El explorador de topics en vivo del portal de ConnectMQ muestra entonces tus mensajes con marca de tiempo a medida que llegan, lo que facilita detectar un dispositivo cuyo ts se queda atrás.
Empieza con ConnectMQ
Marcas de tiempo precisas en el dispositivo más un broker fiable te dan datos en los que puedes confiar. ConnectMQ es un broker MQTT gestionado con TLS, MQTT 3.1.1 y 5.0, y un plan gratuito Serverless que no requiere tarjeta de crédito. Crea un broker, graba este sketch con tus credenciales y explora el resto de la serie del tutorial de IoT con ESP32 para ver proyectos con sensores que se basan en él.
Preguntas frecuentes
¿El broker MQTT añade una marca de tiempo a los mensajes?
El protocolo MQTT no tiene un campo de marca de tiempo, así que los suscriptores solo conocen la hora de llegada. Incluye la hora de captura en el payload, o en una user property de MQTT 5, a partir de un reloj de dispositivo sincronizado.
¿Debo enviar ISO 8601 o milisegundos epoch?
ISO 8601 en UTC es legible y se analiza en casi cualquier sitio; los milisegundos epoch son compactos y se ordenan de forma natural. Muchos proyectos envían ambos durante el desarrollo y se quedan con uno en producción.
¿Con qué frecuencia debe resincronizarse el ESP32 con NTP?
La resincronización horaria por defecto basta para la mayoría de la telemetría. Resincroniza tras periodos largos de deep sleep, porque el reloj de sueño deriva más que el cristal principal.
¿Y si mi dispositivo no tiene acceso a internet para NTP?
Ejecuta un servidor NTP en una pasarela o router local y apunta configTime hacia él. Sin ninguna fuente de hora, publica el tiempo de funcionamiento y deja que el consumidor ponga la marca de tiempo al recibir.



