Connect your first device
Working telemetry in a few minutes - no OAuth, no dashboards-first, no reading the full API reference.
- Sign in and create a project
- Add a device - its MQTT credential is issued automatically. Copy the MQTT username, password, and topic id from the device's Credentials.
- Connect to
mqtts://mqtt.luminatti.online:8883and authenticate with the username/password - Sign and publish a
temperaturereading to your telemetry topic, and watch it appear in IoT Manager
sequence must be strictly greater than the last value this device ever sent - across restarts, and across transports. Seed it from the wall clock (int(time.time())) and increment. A counter that starts at 0 or 1 works exactly once and is then silently dropped forever.
Pick your hardware
Both examples do the same four things: open a TLS connection to the broker, sign one temperature reading with LM1 (HMAC-SHA256), give it an increasing sequence, and publish it to your telemetry topic. Switch the tab on the right for your platform.
No official IoT Manager SDK exists yet, so both examples talk MQTT directly using a well-known library for their platform - signing is a handful of lines, not a dependency. The full wire format is in the <a href="/docs/mqtt">protocol reference</a>.
Nothing is hardcoded: both read their configuration from the environment. The Python example imports the shared signing helper below; download both files from <a href="/docs/samples">Samples</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"
Expected result: the broker connects (both platforms print it), then temperature shows 24.7°C in IoT Manager. There is no per-message ACK on MQTT - the QoS 1 PUBACK only means the broker took your frame, so confirm in the dashboard.
Something failed?
- CONNECT refused - the username must be the MQTT username shown in Credentials, and
clientidmust equal that same username; a mistyped or regenerated password (regenerating kills the old one) is refused too. - Connects but the reading never shows up - your payload is signed with the wrong password, or your device clock has drifted (sync it via NTP); a bad signature is silently dropped, not reported back.
- It worked once and never again - your
sequencerestarted low. Seed it fromint(time.time()), never from0. - Value accepted but no chart? Define the
temperaturedatastream in IoT Manager to give it a unit, bounds, and dashboard visibility. - C++: TLS handshake failures - set the board's clock via NTP before connecting; certificate validation can depend on it.
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())
The shared helper it imports: 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
Install PubSubClient and ArduinoJson from the Arduino Library Manager first - mbedtls/md.h ships with the ESP32 core, no extra install for signing.
// 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);
}
The shared header it includes: 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