Skip to content

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:8883 and authenticate with the username/password
  • Sign and publish a temperature reading to your telemetry topic, and watch it appear in IoT Manager

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>.

bash configuration - from the Credentials panel
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 clientid must 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 sequence restarted low. Seed it from int(time.time()), never from 0.
  • Value accepted but no chart? Define the temperature datastream 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.
bash
pip install paho-mqtt
python samples/python/mqtt/02_publish_temperature.py
"""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
python samples/python/mqtt/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.

cpp samples/cpp/esp32/02_publish_temperature/02_publish_temperature.ino
// 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
cpp samples/cpp/esp32/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

Next step