Conecta tu primer dispositivo
Telemetría funcionando en pocos minutos - sin OAuth, sin empezar por los dashboards, sin leer toda la referencia de la API.
- Inicia sesión y crea un proyecto
- Agregue un dispositivo: su credencial MQTT se emite automáticamente. Copie el usuario MQTT, la contraseña y el id de topic desde las Credenciales del dispositivo.
- Conéctate a
mqtts://mqtt.luminatti.online:8883y autentícate con el usuario/contraseña - Firma y publica una lectura de
temperatureen tu topic de telemetría, y mírala aparecer en IoT Manager
sequence debe ser estrictamente mayor que el último valor que este dispositivo haya enviado, incluso entre reinicios y entre transportes. Inicialícelo con el reloj del sistema (int(time.time())) e incremente. Un contador que empieza en 0 o 1 funciona exactamente una vez y después se descarta en silencio para siempre.
Elige tu hardware
Ambos ejemplos hacen las mismas cuatro cosas: abrir una conexión TLS con el broker, firmar una lectura de temperature con LM1 (HMAC-SHA256), darle un sequence creciente y publicarla en su topic de telemetría. Cambie la pestaña de la derecha según su plataforma.
Todavía no existe un SDK oficial de IoT Manager, así que ambos ejemplos hablan MQTT directamente usando una biblioteca conocida de su plataforma: firmar son unas pocas líneas, no una dependencia. El formato completo en el cable está en <a href="/docs/mqtt">referencia del protocolo</a>.
Nada está escrito en el código: ambos leen su configuración del entorno. El ejemplo de Python importa el ayudante de firma compartido que aparece abajo; descargue ambos archivos desde <a href="/docs/samples">Muestras</a>.
export MQTT_HOST="mqtt.luminatti.online"
export MQTT_PORT="8883"
export DEVICE_ID="your-mqtt-username"
export DEVICE_PASSWORD="your-mqtt-password"
export DEVICE_TOPIC_ID="your-topic-id"
Resultado esperado: el broker conecta (ambas plataformas lo imprimen) y luego temperature muestra 24,7 °C en IoT Manager. MQTT no tiene ACK por mensaje: el PUBACK de QoS 1 solo significa que el broker tomó su frame, así que confirme en el panel.
¿Algo falló?
- CONNECT rechazado - el usuario debe ser el usuario MQTT que se muestra en Credenciales, y
clientiddebe ser igual a ese mismo usuario; una contraseña mal escrita o regenerada (regenerar mata la anterior) también se rechaza. - Se conecta pero la lectura nunca aparece - tu payload está firmado con la contraseña equivocada, o el reloj de tu dispositivo se desincronizó (sincronízalo vía NTP); una firma inválida se descarta en silencio, no se reporta de vuelta.
- Funcionó una vez y nunca más: su
sequencevolvió a empezar bajo. Inicialícelo conint(time.time()), nunca con0. - ¿El valor se aceptó pero no hay gráfico? Define el flujo de datos
temperatureen IoT Manager para darle unidad, límites y visibilidad en el dashboard. - C++: fallos en el handshake TLS - configura el reloj de la placa vía NTP antes de conectar; la validación del certificado puede depender de eso.
pip install paho-mqtt
"""02 - Publish exactly one signed temperature reading.
Adds the two things 01 did not do: build a telemetry body, and sign it
into an LM1 frame before publishing.
Run:
python 02_publish_temperature.py
Expected result: the reading appears in IoT Manager within a couple of
seconds. There is no per-message ACK on MQTT - the QoS 1 PUBACK only
means the broker took the message, not that the backend accepted it.
Check the dashboard (or sample 07's command round trip) to confirm.
"""
from __future__ import annotations
import json
import sys
import threading
import time
from datetime import UTC, datetime
import paho.mqtt.client as mqtt
from common.lm1 import SequenceCounter, load_config, sign_frame
def main() -> int:
config = load_config()
topic = config.topics["telemetry"]
sequence = SequenceCounter()
connected = threading.Event()
def on_connect(
client: mqtt.Client, userdata: object, flags: object, reason_code: object, properties: object = None
) -> None:
connected.set()
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=config.username, clean_session=True)
client.username_pw_set(config.username, config.password)
client.tls_set()
client.on_connect = on_connect
client.connect(config.host, config.port, keepalive=30)
client.loop_start()
try:
if not connected.wait(timeout=15):
print("timed out waiting for CONNACK", file=sys.stderr)
return 1
body = json.dumps({
"timestamp": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
# Strictly increasing per device, seeded from wall-clock time -
# see SequenceCounter's docstring for why 0/1 is a trap.
"sequence": sequence.next(),
"values": {"temperature": {"value": 24.7, "unit": "C", "quality": "valid"}},
}).encode()
# Sign the EXACT bytes being published - never re-serialize the body.
frame = sign_frame(topic, body, config.password)
info = client.publish(topic, frame, qos=1)
info.wait_for_publish(timeout=10)
print(f"published {len(frame)} bytes to {topic} (rc={info.rc})")
time.sleep(1) # let paho flush the PUBACK exchange before disconnecting
return 0
finally:
client.loop_stop()
client.disconnect()
if __name__ == "__main__":
raise SystemExit(main())
El ayudante compartido que importa: common/lm1.py
"""LM1 frame signing/verification + topic and configuration helpers.
This is the device-side mirror of the backend's own
`src/iot_manager/mqtt/signing.py` and `src/iot_manager/mqtt/topics.py`.
Every other sample in this folder imports from here, so the signing rules
exist exactly once.
Frame format
------------
LM1.<mac>.<ts>.<body>
* `mac` - lowercase hex HMAC-SHA256 over the exact bytes
`topic + "\\n" + ts + "\\n" + body`.
* `ts` - Unix time in seconds, as decimal digits. The backend rejects a
frame older than 3600 s or more than 300 s in the future, so keep the
device clock NTP-synced.
* `body` - the exact JSON bytes you are publishing. Sign the bytes you
send, never a re-serialized structure: key order, whitespace and float
formatting all change the MAC.
The signing key is the device's own MQTT password - the same secret you
CONNECT with. Nothing extra to store.
"""
from __future__ import annotations
import hashlib
import hmac
import os
import threading
import time
from dataclasses import dataclass
_SCHEME_PREFIX = b"LM1."
# Freshness window enforced by the backend (config/settings.py:
# MQTT_SIGNATURE_MAX_AGE_SECONDS / MQTT_SIGNATURE_MAX_FUTURE_SKEW_SECONDS).
MAX_AGE_S = 3600
MAX_FUTURE_SKEW_S = 300
class SignatureError(Exception):
"""A received frame is malformed, forged, or outside the freshness window."""
# ------------------------------------------------------------------
# Signing / verification
# ------------------------------------------------------------------
def _compute_mac(topic: str, ts_bytes: bytes, body: bytes, key: str) -> str:
signed_string = topic.encode() + b"\n" + ts_bytes + b"\n" + body
return hmac.new(key.encode(), signed_string, hashlib.sha256).hexdigest()
def sign_frame(topic: str, body: bytes, key: str, ts: int | None = None) -> bytes:
"""Build the signed LM1 frame to publish for `body`."""
if ts is None:
ts = int(time.time())
ts_bytes = str(ts).encode()
mac = _compute_mac(topic, ts_bytes, body, key)
return _SCHEME_PREFIX + mac.encode() + b"." + ts_bytes + b"." + body
def verify_frame(topic: str, frame: bytes, key: str) -> bytes:
"""Verify an inbound LM1 frame (a command) and return its body bytes.
Raises `SignatureError` on any failure - never returns an empty body
for a rejected frame, so a caller cannot mistake one for the other.
The MAC is checked before the timestamp, because `ts` is itself signed.
"""
if not frame.startswith(_SCHEME_PREFIX):
raise SignatureError("missing LM1 prefix")
try:
mac_hex, ts_bytes, body = frame[len(_SCHEME_PREFIX):].split(b".", 2)
except ValueError:
raise SignatureError("expected LM1.<mac>.<ts>.<body>") from None
if not mac_hex or not ts_bytes:
raise SignatureError("empty mac or timestamp field")
try:
ts = int(ts_bytes)
except ValueError:
raise SignatureError("non-integer timestamp") from None
expected = _compute_mac(topic, ts_bytes, body, key).encode()
if not hmac.compare_digest(mac_hex, expected):
raise SignatureError("MAC mismatch")
now = time.time()
if ts < now - MAX_AGE_S:
raise SignatureError("stale timestamp")
if ts > now + MAX_FUTURE_SKEW_S:
raise SignatureError("timestamp too far in the future")
return body
# ------------------------------------------------------------------
# Topics
# ------------------------------------------------------------------
def device_topics(prefix: str, topic_id: str) -> dict[str, str]:
"""The only three topics a device may ever touch.
One `telemetry` topic carries both a single reading and a batch
(`{"messages": [...]}`) - the backend sniffs the body's shape. There
is no status/presence topic: the dashboard marks a device offline on
a per-device timeout instead, so there is no birth message or Last
Will to publish.
"""
base = f"{prefix}/{topic_id}"
return {
"telemetry": f"{base}/telemetry",
"cmd": f"{base}/cmd",
"cmdAck": f"{base}/cmd/ack",
}
# ------------------------------------------------------------------
# Configuration (environment variables only - never hardcode a secret)
# ------------------------------------------------------------------
@dataclass(frozen=True)
class DeviceConfig:
"""Everything a device needs, read from the environment.
Where each value comes from in the IoT Manager UI:
* MQTT_HOST / MQTT_PORT - the broker address shown on the device's
Credentials panel (mqtt.luminatti.online, port 8883, TLS only).
* DEVICE_ID - the *MQTT username* on that same panel. The broker
also requires the MQTT clientid to equal it, so the samples pass
it for both.
* DEVICE_PASSWORD - the `dvp_` MQTT password from the panel (shown
once, when created or regenerated). A legacy `dvk_` Device API Key
is also accepted as the MQTT password. Whichever one you connect
with is ALSO your HMAC signing key.
* DEVICE_TOPIC_ID - the short, opaque `topic_id` on the panel. It is
not your device id, and it can be rotated on its own.
* MQTT_TOPIC_PREFIX - `iot/v1` unless your deployment says otherwise.
"""
host: str
port: int
username: str
password: str
topic_id: str
topic_prefix: str
@property
def topics(self) -> dict[str, str]:
return device_topics(self.topic_prefix, self.topic_id)
def load_config() -> DeviceConfig:
"""Read the device configuration from environment variables.
Exits with a clear message when something is missing, instead of
failing later with an opaque CONNECT refusal.
"""
missing = [name for name in ("DEVICE_ID", "DEVICE_PASSWORD", "DEVICE_TOPIC_ID") if not os.getenv(name)]
if missing:
raise SystemExit(
"Missing environment variable(s): "
+ ", ".join(missing)
+ "\nSet them from your device's Credentials panel in IoT Manager. Example:\n"
' export DEVICE_ID="your-mqtt-username"\n'
' export DEVICE_PASSWORD="your-mqtt-password"\n'
' export DEVICE_TOPIC_ID="your-topic-id"'
)
return DeviceConfig(
host=os.getenv("MQTT_HOST", "mqtt.luminatti.online"),
port=int(os.getenv("MQTT_PORT", "8883")),
username=os.environ["DEVICE_ID"],
password=os.environ["DEVICE_PASSWORD"],
topic_id=os.environ["DEVICE_TOPIC_ID"],
topic_prefix=os.getenv("MQTT_TOPIC_PREFIX", "iot/v1"),
)
# ------------------------------------------------------------------
# Sequence numbers
# ------------------------------------------------------------------
class SequenceCounter:
"""Strictly-increasing `sequence` values for this device.
Seeded from wall-clock time, NOT from 0 or 1. The backend keeps a
durable high-water mark per device and drops any message whose
`sequence` is <= the last one it accepted, so a counter that restarts
low means every message after a reboot is silently discarded - and a
device that previously used another transport already has a high mark
on file. `int(time.time())` clears any plausible previous value and
keeps increasing across restarts.
Sequences must also strictly increase WITHIN a batch: call `next()`
once per message you put in the `messages` list, in order.
Thread-safe: the samples that answer commands call this from paho's
network thread as well as from their own loop.
"""
def __init__(self) -> None:
self._lock = threading.Lock()
self._value = int(time.time())
def next(self) -> int:
with self._lock:
self._value += 1
return self._value
Primero instala PubSubClient y ArduinoJson desde el Arduino Library Manager - mbedtls/md.h viene incluido con el núcleo de ESP32, sin instalación extra para firmar.
// 02 - Publish exactly one signed temperature reading.
//
// Adds the two things sketch 01 did not do: build a telemetry body, and
// sign it into an LM1 frame before publishing.
//
// Two gotchas this sketch handles explicitly:
// * PubSubClient's buffer defaults to 256 bytes and it SILENTLY drops
// anything larger. A signed frame carries an 80-byte prefix on top of
// the body, so raise it with setBufferSize().
// * `sequence` is seeded from wall-clock time, never from 0 or 1 - see
// Lm1Sequence in lm1.h for why.
//
// Libraries: PubSubClient, ArduinoJson (Library Manager).
//
// Expected serial output: "published <n> bytes" and the reading showing
// up in IoT Manager within a couple of seconds. There is no per-message
// ACK on MQTT: the QoS 1 PUBACK only means the broker took the frame.
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include "lm1.h"
WiFiClientSecure tlsClient;
PubSubClient mqttClient(tlsClient);
Lm1Sequence sequence;
bool publishTemperature(float celsius) {
JsonDocument doc;
doc["sequence"] = sequence.next();
JsonObject value = doc["values"]["temperature"].to<JsonObject>();
value["value"] = celsius;
value["unit"] = "C";
value["quality"] = "valid";
String body;
serializeJson(doc, body);
String topic = lm1TelemetryTopic();
String frame = lm1SignFrame(topic, body, DEVICE_PASSWORD);
if (frame.length() > LM1_MAX_FRAME_BYTES) {
Serial.println("frame too large - the backend drops anything over 64 KB");
return false;
}
// retained = false. Retained messages are disabled platform-wide.
bool ok = mqttClient.publish(topic.c_str(), (const uint8_t *)frame.c_str(), frame.length(), false);
Serial.print(ok ? "published " : "publish FAILED at ");
Serial.print(frame.length());
Serial.println(" bytes");
return ok;
}
void setup() {
Serial.begin(115200);
delay(500);
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED) {
delay(250);
}
if (!lm1WaitForClock()) {
Serial.println("NTP sync failed - refusing to sign with a wrong clock");
return;
}
sequence.begin(); // seed AFTER the clock is real
tlsClient.setInsecure(); // bring-up only; pin a CA certificate in production
mqttClient.setServer(MQTT_HOST, MQTT_PORT);
mqttClient.setKeepAlive(30);
// Default is 256 bytes and oversized publishes fail silently.
mqttClient.setBufferSize(1024);
if (!mqttClient.connect(DEVICE_ID, DEVICE_ID, DEVICE_PASSWORD)) {
Serial.print("mqtt FAILED, state=");
Serial.println(mqttClient.state());
return;
}
Serial.println("mqtt ok");
publishTemperature(24.7f);
}
void loop() {
mqttClient.loop();
delay(10);
}
La cabecera compartida que incluye: common/src/lm1.h
// lm1.h - LM1 frame signing/verification + topic helpers for ESP32.
//
// Device-side mirror of the backend's src/iot_manager/mqtt/signing.py and
// src/iot_manager/mqtt/topics.py. Header-only; HMAC-SHA256 comes from the
// mbedtls that already ships with the ESP32 Arduino core, so there is no
// extra library to install for signing.
//
// Frame format:
// LM1.<mac>.<ts>.<body>
// where <mac> is the lowercase-hex HMAC-SHA256 over the exact bytes
// topic + "\n" + ts + "\n" + body
// and the key is the device's own MQTT password.
//
// The device clock MUST be NTP-synced (configTime(...) in setup(), then
// wait for time(nullptr) to pass ~1.7e9). The backend rejects a frame
// whose ts is more than 3600 s old or more than 300 s in the future, and
// an unsynced ESP32 starts at the epoch, so every frame it signs before
// the first sync is rejected without any error coming back.
//
// Configuration comes from compile-time defines so no secret ever lives
// in a committed source file. Inject them from your environment, e.g.
//
// arduino-cli compile --fqbn esp32:esp32:esp32 \
// --build-property "compiler.cpp.extra_flags=\
// -DWIFI_SSID=\"$WIFI_SSID\" -DWIFI_PASSWORD=\"$WIFI_PASSWORD\" \
// -DDEVICE_ID=\"$DEVICE_ID\" -DDEVICE_PASSWORD=\"$DEVICE_PASSWORD\" \
// -DDEVICE_TOPIC_ID=\"$DEVICE_TOPIC_ID\"" 01_wifi_mqtt_connect
//
// Where each value comes from in the IoT Manager UI:
// MQTT_HOST / MQTT_PORT the broker on the device's Credentials panel
// (mqtt.luminatti.online : 8883, TLS only)
// DEVICE_ID the MQTT username on that panel; the broker
// also requires the clientid to equal it
// DEVICE_PASSWORD the dvp_ MQTT password from the panel, shown
// once at creation. It is ALSO the signing key.
// DEVICE_TOPIC_ID the short opaque topic_id on the panel
// MQTT_TOPIC_PREFIX "iot/v1" unless your deployment says otherwise
#ifndef LUMINATTI_LM1_H
#define LUMINATTI_LM1_H
#include <Arduino.h>
#include <mbedtls/md.h>
#include <time.h>
#ifndef MQTT_HOST
#define MQTT_HOST "mqtt.luminatti.online"
#endif
#ifndef MQTT_PORT
#define MQTT_PORT 8883
#endif
#ifndef MQTT_TOPIC_PREFIX
#define MQTT_TOPIC_PREFIX "iot/v1"
#endif
#ifndef DEVICE_ID
#define DEVICE_ID "" // set from the environment - see the header comment
#endif
#ifndef DEVICE_PASSWORD
#define DEVICE_PASSWORD ""
#endif
#ifndef DEVICE_TOPIC_ID
#define DEVICE_TOPIC_ID ""
#endif
#ifndef WIFI_SSID
#define WIFI_SSID ""
#endif
#ifndef WIFI_PASSWORD
#define WIFI_PASSWORD ""
#endif
// Freshness window enforced by the backend.
static const long LM1_MAX_AGE_S = 3600;
static const long LM1_MAX_FUTURE_SKEW_S = 300;
// Payload cap: the backend drops any frame over 64 KB before verifying it.
static const size_t LM1_MAX_FRAME_BYTES = 65536;
// ---------------------------------------------------------------------
// Topics - the only three a device may ever touch. There is no status or
// presence topic: the dashboard marks a device offline on a timeout, so
// there is no birth message and no Last Will to configure.
// ---------------------------------------------------------------------
inline String lm1TelemetryTopic() {
return String(MQTT_TOPIC_PREFIX) + "/" + DEVICE_TOPIC_ID + "/telemetry";
}
inline String lm1CommandTopic() {
return String(MQTT_TOPIC_PREFIX) + "/" + DEVICE_TOPIC_ID + "/cmd";
}
inline String lm1CommandAckTopic() {
return String(MQTT_TOPIC_PREFIX) + "/" + DEVICE_TOPIC_ID + "/cmd/ack";
}
// ---------------------------------------------------------------------
// HMAC-SHA256
// ---------------------------------------------------------------------
inline String lm1HmacSha256Hex(const String &key, const String &message) {
byte digest[32];
mbedtls_md_context_t ctx;
mbedtls_md_init(&ctx);
mbedtls_md_setup(&ctx, mbedtls_md_info_from_type(MBEDTLS_MD_SHA256), 1);
mbedtls_md_hmac_starts(&ctx, (const unsigned char *)key.c_str(), key.length());
mbedtls_md_hmac_update(&ctx, (const unsigned char *)message.c_str(), message.length());
mbedtls_md_hmac_finish(&ctx, digest);
mbedtls_md_free(&ctx);
String hex;
hex.reserve(64);
for (int i = 0; i < 32; i++) {
char pair[3];
snprintf(pair, sizeof(pair), "%02x", digest[i]);
hex += pair;
}
return hex;
}
// ---------------------------------------------------------------------
// Sign / verify
// ---------------------------------------------------------------------
// Builds the frame to publish. Sign the EXACT body bytes you are about to
// send - never a re-serialized copy: key order, whitespace and float
// formatting all change the MAC.
inline String lm1SignFrame(const String &topic, const String &body, const String &key) {
String ts = String((unsigned long)time(nullptr));
String mac = lm1HmacSha256Hex(key, topic + "\n" + ts + "\n" + body);
return "LM1." + mac + "." + ts + "." + body;
}
// Constant-time comparison of two equal-length hex MACs. A plain `==`
// leaks how many leading characters matched through its timing; that is
// exactly what the backend's hmac.compare_digest avoids, and firmware
// answering commands should do the same.
inline bool lm1SecureEquals(const String &a, const String &b) {
if (a.length() != b.length()) {
return false;
}
uint8_t diff = 0;
for (size_t i = 0; i < a.length(); i++) {
diff |= (uint8_t)(a[i] ^ b[i]);
}
return diff == 0;
}
// Verifies an inbound frame (a command from the backend). On success
// writes the body into `bodyOut` and returns true; returns false and
// leaves `bodyOut` untouched on any failure.
//
// The MAC is checked BEFORE the timestamp, because ts is itself signed:
// checking freshness on an unauthenticated frame would only leak
// information to someone with no valid key.
inline bool lm1VerifyFrame(const String &topic, const String &frame, const String &key, String &bodyOut) {
if (!frame.startsWith("LM1.")) {
return false;
}
int firstDot = frame.indexOf('.', 4);
if (firstDot < 0) {
return false;
}
int secondDot = frame.indexOf('.', firstDot + 1);
if (secondDot < 0) {
return false;
}
String mac = frame.substring(4, firstDot);
String ts = frame.substring(firstDot + 1, secondDot);
String body = frame.substring(secondDot + 1);
if (mac.length() == 0 || ts.length() == 0) {
return false;
}
String expected = lm1HmacSha256Hex(key, topic + "\n" + ts + "\n" + body);
if (!lm1SecureEquals(mac, expected)) {
return false;
}
long age = (long)time(nullptr) - ts.toInt();
if (age > LM1_MAX_AGE_S || age < -LM1_MAX_FUTURE_SKEW_S) {
return false;
}
bodyOut = body;
return true;
}
// ---------------------------------------------------------------------
// Sequence numbers
// ---------------------------------------------------------------------
//
// The backend keeps a durable high-water mark per device and drops any
// message whose `sequence` is <= the last one it accepted. A counter that
// restarts at 0 or 1 therefore works exactly once and is silently ignored
// after every reboot - and a device that previously used another
// transport already has a high mark on file. Seed from wall-clock time
// (after NTP sync) so the counter clears any previous value and keeps
// increasing across restarts.
//
// Sequences must also strictly increase WITHIN a batch: call next() once
// per message, in the order they appear in the "messages" array.
class Lm1Sequence {
public:
void begin() { value_ = (unsigned long)time(nullptr); }
unsigned long next() { return ++value_; }
private:
unsigned long value_ = 0;
};
// Blocks until the clock is plausibly NTP-synced. Call after WiFi is up
// and before signing anything.
inline bool lm1WaitForClock(unsigned long timeoutMs = 20000) {
configTime(0, 0, "pool.ntp.org", "time.nist.gov");
unsigned long start = millis();
while (time(nullptr) < 1700000000L) {
if (millis() - start > timeoutMs) {
return false;
}
delay(200);
}
return true;
}
#endif // LUMINATTI_LM1_H