Skip to main content

Ingesting Telemetry via TTN MQTT

Applicable models

Applicable to the Cubicore Devboard (ESP32-S3 + SX1262 on RAK3112 module) provisioned with The Things Network (TTN).

Product status

Confirm the current hardware revision, pinout allocations, and supported firmware features in the Cubicore Devboard product documentation before deploying in field production.

While Chapter 7: Integrating with Cubicore Cloud demonstrates zero-code cloud visualization using HTTP webhooks, many IoT deployments require direct, low-latency streaming into self-hosted databases, home automation hubs, edge controllers, or custom backend services.

The Things Stack (TTS / TTN) includes a built-in, production-grade MQTT broker on every regional cluster. By subscribing to the MQTT broker, your applications can ingest live sensor telemetry as it arrives, operate securely behind corporate firewalls without public IP addresses, and publish bidirectional downlink commands back to the Cubicore Devboard.


1. Architecture: Webhooks vs. MQTT​

Understanding when to select MQTT over HTTP Webhooks is fundamental to IoT system architecture:

Feature / CapabilityHTTP Webhooks (Cubicore Cloud)TTN MQTT Broker
Ingress RequirementRequires a public HTTPS endpoint with a valid SSL/TLS certificate.Outbound TCP/TLS connection only. Works seamlessly behind NAT, home routers, and firewalls.
Connection ModelStateless HTTP POST requests dispatched per uplink packet.Persistent, stateful TCP socket with automatic keep-alive pings.
Bidirectional SupportPrimarily push-only (uplinks). Downlinks require separate REST API calls.Unified bidirectional pipeline: subscribe to uplinks on /up and publish downlinks on /down/push.
Ideal Use CasesManaged cloud SaaS, serverless web functions, third-party webhook receivers.Python automation scripts, Home Assistant, Node-RED, local edge gateways, timeseries databases (InfluxDB/TimescaleDB).

Telemetry Signal Flow​

  1. Cubicore Devboard Node (ESP32-S3 + SX1262): Samples environmental sensors and broadcasts encrypted LoRaWAN packets over sub-GHz radio (916.6 / 916.8 MHz).
  2. Cubicore Gateway Pocket / Hub: Demodulates radio frames and forwards UDP packets over local Wi-Fi or Ethernet backhaul to the cloud.
  3. The Things Network (TTN) Cluster Server: Decrypts the payload, validates Message Integrity Codes (MIC), updates frame counters, and dispatches data across two parallel ingress pipelines:
    • HTTP Webhook Pipeline: Dispatches stateless JSON POST requests directly to Cubicore Cloud SaaS dashboards for zero-configuration charting.
    • MQTT Pipeline (Port 8883 TLS): Publishes messages across a persistent stateful TCP socket to custom developer infrastructure (Python 3 subscribers, InfluxDB historians, Home Assistant, Node-RED).

2. Regional Cluster Endpoints​

The Things Network provides dedicated MQTT broker endpoints per cluster. You must connect to the cluster hosting your TTN Application:

Cluster RegionPrimary GeographyMQTT Broker HostnamePlaintext PortTLS/SSL Port (Recommended)
Australia / Southeast Asia (au1)Philippines, Asia-Pacific, Australiaau1.cloud.thethings.network18838883
Europe (eu1)Europe, Africa, Middle Easteu1.cloud.thethings.network18838883
North America (nam1)United States, Canada, Americasnam1.cloud.thethings.network18838883
Port Selection

Always utilize Port 8883 with TLS encryption in production. Plaintext port 1883 transmits credentials and sensor payloads unencrypted across public networks.


3. Step 1: Generating MQTT Credentials in TTN Console​

To authenticate with the MQTT broker, you need two credentials:

  1. Username: Your TTN Application ID followed by the @ttn suffix (e.g., cubicore-environmental-node@ttn).
  2. Password: An API Key generated with explicit traffic access rights.

Step-by-Step Procedure​

  1. Sign in to your The Things Network Console.
  2. Select your cluster (e.g., Australia 1 or Europe 1).
  3. In the top navigation, click Applications and open your registered Devboard application.
  4. In the left-hand sidebar, navigate to Integrations → MQTT.
  5. Scroll down to the Connection information section and review the pre-configured parameters:
    • Public TLS address: <region>.cloud.thethings.network:8883
    • Username: <your-application-id>@ttn
  6. Under the API keys section, click Generate API key.
  7. In the configuration modal:
    • Name: Enter a descriptive identifier (e.g., Cubicore-Local-Subscriber-Key).
    • Rights: Select Grant individual rights:
      • Read application traffic (uplink and downlink) (Mandatory to receive telemetry).
      • Write downlink traffic (Optional, required if you intend to send downlink commands).
  8. Click Generate API key.
  9. Copy the generated API key immediately and store it in a secure location. TTN will never display this key again after closing the modal.
API Key Security

Treat your TTN API key like a root password. Never hardcode API keys into public Git repositories. Use environment variables or a .env file when developing client scripts.


4. Step 2: TTN MQTT Topic Hierarchy​

The Things Stack structures all MQTT events into a predictable hierarchical topic tree.

Topic PatternDescription
v3/<app-id>@ttn/devices/<device-id>/upReceives uplink telemetry packets from a specific Devboard node.
v3/<app-id>@ttn/devices/+/upWildcard subscription: receives uplinks from all registered devices in the application.
v3/<app-id>@ttn/devices/<device-id>/joinEmits an event whenever a Devboard successfully negotiates an OTAA activation handshake.
Topic PatternDescription
v3/<app-id>@ttn/devices/<device-id>/down/pushTarget topic used by your client to enqueue a downlink command for transmission.
v3/<app-id>@ttn/devices/<device-id>/down/queuedEmits notification when a downlink message is accepted into TTN's outbound queue.
v3/<app-id>@ttn/devices/<device-id>/down/sentEmits notification when the gateway transmits the downlink packet over the airwaves.
v3/<app-id>@ttn/devices/<device-id>/down/ackEmits confirmation when the node acknowledges receipt of a confirmed downlink.

When a Cubicore Devboard transmits a packet, TTN publishes a comprehensive JSON document to the /up topic.

Here is an example payload received from a Cubicore Devboard running the Chapter 6 SHT30 Environmental Sketch:

{
"end_device_ids": {
"device_id": "cubicore-devboard-node-01",
"application_ids": {
"application_id": "cubicore-environmental-node"
},
"dev_eui": "70B3D57ED006ABCD",
"join_eui": "0000000000000000"
},
"received_at": "2026-10-12T17:15:32.418Z",
"uplink_message": {
"f_port": 10,
"f_cnt": 42,
"frm_payload": "C5EC920F00",
"decoded_payload": {
"temperature_c": 28.5,
"humidity_rh": 65.2,
"battery_voltage": 3.85,
"status_flags": 0
},
"rx_metadata": [
{
"gateway_ids": {
"gateway_id": "cubicore-gateway-pocket-ch1",
"eui": "58A0CBFFFE801234"
},
"time": "2026-10-12T17:15:32.189Z",
"timestamp": 24891024,
"rssi": -68,
"channel_rssi": -68,
"snr": 9.5,
"channel_index": 0
}
],
"settings": {
"data_rate": {
"lora": {
"bandwidth": 125000,
"spreading_factor": 10,
"coding_rate": "4/5"
}
},
"frequency": "916600000"
}
}
}

Key Fields for Data Ingestion​

  1. uplink_message.decoded_payload: Contains clean, human-readable JSON fields produced by the JavaScript Payload Formatter configured in TTN.
  2. uplink_message.f_cnt: Frame counter. Increments sequentially with each uplink; vital for detecting dropped packets.
  3. uplink_message.rx_metadata[0].rssi: Received Signal Strength Indicator in dBm. Values between -50 dBm and -90 dBm indicate strong links; below -120 dBm indicates marginal signal.
  4. uplink_message.rx_metadata[0].snr: Signal-to-Noise Ratio in dB. Positive values (+6 to +12 dB) represent clean signals; negative values down to -20 dB demonstrate LoRa's sub-noise demodulation capabilities.
  5. uplink_message.settings.frequency: Confirm that incoming packets match the Philippine AS923-3 dual channels (916600000 or 916800000 Hz).

6. Step 4: Rapid Validation with Mosquitto CLI​

Before writing application code, verify your broker credentials and topic subscription using the standard mosquitto_sub command-line utility.

macOS Installation (via Homebrew)​

brew install mosquitto

Ubuntu / Debian Installation​

sudo apt-get update && sudo apt-get install -y mosquitto-clients

Execute the following command, replacing placeholders with your TTN credentials:

mosquitto_sub \
-h au1.cloud.thethings.network \
-p 8883 \
-u "cubicore-environmental-node@ttn" \
-P "NNSXS.YOUR_API_KEY_HERE..." \
-t "v3/+/devices/+/up" \
--capath /etc/ssl/certs

(On macOS, if --capath is omitted, mosquitto_sub automatically utilizes the system keychain root certificates).

When your Cubicore Devboard transmits an uplink, the raw JSON payload immediately prints to your terminal, validating the full end-to-end radio and network pipeline.


7. Step 5: Production Python Telemetry Subscriber​

The following Python script provides an asynchronous, production-grade telemetry subscriber using the industry-standard paho-mqtt library (v2.x compatible). It establishes an encrypted TLS socket, subscribes to all device uplinks, extracts decoded sensor data, and writes records into a local SQLite database (cubicore_telemetry.db).

1. Install Dependencies​

pip install "paho-mqtt>=2.0.0"

2. Subscriber Script (cubicore_subscriber.py)​

#!/usr/bin/env python3
"""
Cubicore Devboard — TTN MQTT Telemetry Ingestion Pipeline
Compatible with Paho MQTT 2.x and The Things Stack v3.
"""

import json
import logging
import os
import sqlite3
import sys
import time
from datetime import datetime
import paho.mqtt.client as mqtt

# ---------------------------------------------------------------------------
# 1. Broker Configuration
# ---------------------------------------------------------------------------
MQTT_BROKER = os.getenv("TTN_BROKER", "au1.cloud.thethings.network")
MQTT_PORT = int(os.getenv("TTN_PORT", "8883"))
APP_ID = os.getenv("TTN_APP_ID", "cubicore-environmental-node")
API_KEY = os.getenv("TTN_API_KEY", "NNSXS.YOUR_API_KEY_HERE")

# Wildcard subscription: receives uplinks from all devices in this application
TOPIC_UPLINK = f"v3/{APP_ID}@ttn/devices/+/up"

DB_FILE = "cubicore_telemetry.db"

# ---------------------------------------------------------------------------
# 2. Logging Setup
# ---------------------------------------------------------------------------
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[logging.StreamHandler(sys.stdout)]
)
logger = logging.getLogger("CubicoreSubscriber")

# ---------------------------------------------------------------------------
# 3. Database Initialization
# ---------------------------------------------------------------------------
def init_database():
"""Initializes local SQLite database schema for timeseries storage."""
with sqlite3.connect(DB_FILE) as conn:
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS telemetry (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
device_id TEXT NOT NULL,
fcnt INTEGER NOT NULL,
temperature REAL,
humidity REAL,
battery_voltage REAL,
rssi INTEGER,
snr REAL,
frequency TEXT
)
""")
conn.commit()
logger.info(f"Initialized local SQLite database: {DB_FILE}")

def record_telemetry(device_id, fcnt, data, rssi, snr, freq):
"""Inserts an incoming packet record into the database."""
with sqlite3.connect(DB_FILE) as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO telemetry (
timestamp, device_id, fcnt,
temperature, humidity, battery_voltage,
rssi, snr, frequency
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
datetime.utcnow().isoformat() + "Z",
device_id,
fcnt,
data.get("temperature_c"),
data.get("humidity_rh"),
data.get("battery_voltage"),
rssi,
snr,
freq
))
conn.commit()

# ---------------------------------------------------------------------------
# 4. Paho MQTT Callbacks (v2.x API)
# ---------------------------------------------------------------------------
def on_connect(client, userdata, flags, rc, properties=None):
if rc == 0:
logger.info(f"Connected to TTN MQTT Broker ({MQTT_BROKER}:{MQTT_PORT})")
client.subscribe(TOPIC_UPLINK)
logger.info(f"Subscribed to topic: {TOPIC_UPLINK}")
else:
logger.error(f"Connection failed with return code: {rc}")

def on_disconnect(client, userdata, rc, properties=None):
if rc != 0:
logger.warning(f"Unexpected disconnection (rc={rc}). Reconnecting in background...")

def on_message(client, userdata, msg):
try:
payload = json.loads(msg.payload.decode("utf-8"))

device_id = payload.get("end_device_ids", {}).get("device_id", "unknown")
uplink = payload.get("uplink_message", {})
fcnt = uplink.get("f_cnt", 0)
decoded = uplink.get("decoded_payload", {})

# Extract signal metadata from primary receiving gateway
rx_meta = uplink.get("rx_metadata", [{}])[0]
rssi = rx_meta.get("rssi", None)
snr = rx_meta.get("snr", None)
freq = uplink.get("settings", {}).get("frequency", "unknown")

logger.info(f"─── UPLINK RECEIVED ─── [{device_id}] (FCnt: {fcnt})")
logger.info(f" Sensors : Temp={decoded.get('temperature_c')}°C | "
f"Humidity={decoded.get('humidity_rh')}% | "
f"VBat={decoded.get('battery_voltage')}V")
logger.info(f" RF Link : RSSI={rssi} dBm | SNR={snr} dB | Freq={freq} Hz")

# Persist to local database
record_telemetry(device_id, fcnt, decoded, rssi, snr, freq)

except json.JSONDecodeError:
logger.error("Failed to decode incoming message as valid JSON.")
except Exception as e:
logger.error(f"Error processing message: {e}", exc_info=True)

# ---------------------------------------------------------------------------
# 5. Main Execution Loop
# ---------------------------------------------------------------------------
def main():
if "YOUR_API_KEY_HERE" in API_KEY:
logger.error("Please configure your TTN_API_KEY before running the script.")
sys.exit(1)

init_database()

# Initialize client with modern Paho Callback API v2
client = mqtt.Client(
mqtt.CallbackAPIVersion.VERSION2,
client_id=f"cubicore-client-{int(time.time())}"
)

client.username_pw_set(f"{APP_ID}@ttn", API_KEY)

# Configure TLS encryption (uses OS trusted root CA bundle)
client.tls_set()

client.on_connect = on_connect
client.on_disconnect = on_disconnect
client.on_message = on_message

logger.info(f"Initiating TLS connection to {MQTT_BROKER}:{MQTT_PORT}...")

try:
client.connect(MQTT_BROKER, MQTT_PORT, keepalive=60)
client.loop_forever()
except KeyboardInterrupt:
logger.info("Subscriber terminated by user.")
client.disconnect()
except Exception as ex:
logger.critical(f"Fatal connection exception: {ex}")

if __name__ == "__main__":
main()

LoRaWAN is not solely a telemetry ingestion technology. You can issue downlink commands to control actuators, trigger buzzer alarms, or change transmission intervals on the Cubicore Devboard.

Because the Cubicore Devboard operates as a LoRaWAN Class A device to minimize battery consumption, the SX1262 receiver is switched off most of the time:

  1. When you publish an MQTT downlink to TTN, TTN holds the message in an outbound FIFO queue.
  2. When the Devboard transmits its next uplink, it opens two brief receive windows (RX1 at 1s and RX2 at 2s).
  3. TTN immediately dispatches the queued downlink during one of these receive windows.
Devboard TX Uplink -> [ RX1 Window (1s) ] -> [ RX2 Window (2s) ] -> Deep Sleep
|
v
Gateway Dispatches Downlink

To enqueue a downlink, publish an MQTT message to: v3/<application-id>@ttn/devices/<device-id>/down/push

{
"downlinks": [
{
"f_port": 15,
"frm_payload": "AQ==",
"confirmed": false,
"priority": "NORMAL"
}
]
}
  • f_port: Application port number (1–223) used by your firmware to identify the command type.
  • frm_payload: Base64-encoded raw byte array. In this example, "AQ==" decodes to hex 0x01 (e.g., Turn ON Relay / LED). Hex 0x00 encoded as Base64 is "AA==".
  • confirmed: Set to false for unconfirmed commands (recommended) or true if you require cryptographic ACK verification.
#!/usr/bin/env python3
import base64
import json
import os
import paho.mqtt.client as mqtt

MQTT_BROKER = "au1.cloud.thethings.network"
APP_ID = "cubicore-environmental-node"
API_KEY = os.getenv("TTN_API_KEY", "NNSXS.YOUR_API_KEY_HERE")
DEVICE_ID = "cubicore-devboard-node-01"

DOWNLINK_TOPIC = f"v3/{APP_ID}@ttn/devices/{DEVICE_ID}/down/push"

def send_downlink(command_byte: int, f_port: int = 15):
# Encode single byte into Base64
payload_bytes = bytes([command_byte])
b64_payload = base64.b64encode(payload_bytes).decode("ascii")

downlink_message = {
"downlinks": [
{
"f_port": f_port,
"frm_payload": b64_payload,
"confirmed": False,
"priority": "NORMAL"
}
]
}

client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
client.username_pw_set(f"{APP_ID}@ttn", API_KEY)
client.tls_set()

client.connect(MQTT_BROKER, 8883)
client.publish(DOWNLINK_TOPIC, json.dumps(downlink_message))
client.disconnect()

print(f"Enqueued downlink (Byte: 0x{command_byte:02X}, B64: {b64_payload}) to {DOWNLINK_TOPIC}")

if __name__ == "__main__":
# Example: Send 0x01 to trigger active state
send_downlink(0x01, f_port=15)

In your Devboard Arduino sketch, inspect the return state of node.sendReceive() to process incoming downlink payloads:

uint8_t downlinkData[32];
size_t downlinkLen = 0;

int16_t state = node.sendReceive(telemetryPayload, payloadSize, 10, downlinkData, &downlinkLen);

if (state == RADIOLIB_ERR_NONE) {
Serial.println(F("[LoRaWAN] Uplink transmitted successfully!"));

if (downlinkLen > 0) {
Serial.printf("[LoRaWAN] Downlink received! Bytes: %d\n", downlinkLen);
uint8_t command = downlinkData[0];

if (command == 0x01) {
Serial.println(F("[Action] Command 0x01: Turning ON onboard Blue LED (GPIO 45)"));
digitalWrite(45, HIGH);
} else if (command == 0x00) {
Serial.println(F("[Action] Command 0x00: Turning OFF onboard Blue LED"));
digitalWrite(45, LOW);
}
}
}

9. Step 7: Third-Party Ecosystem Integrations​

Home Assistant Integration (configuration.yaml)​

If you run Home Assistant, ingest sensor metrics directly without custom code using the native MQTT integration:

mqtt:
sensor:
- name: "Cubicore Node Temperature"
state_topic: "v3/cubicore-environmental-node@ttn/devices/cubicore-devboard-node-01/up"
value_template: "{{ value_json.uplink_message.decoded_payload.temperature_c }}"
unit_of_measurement: "°C"
device_class: "temperature"
state_class: "measurement"

- name: "Cubicore Node Humidity"
state_topic: "v3/cubicore-environmental-node@ttn/devices/cubicore-devboard-node-01/up"
value_template: "{{ value_json.uplink_message.decoded_payload.humidity_rh }}"
unit_of_measurement: "%"
device_class: "humidity"
state_class: "measurement"

- name: "Cubicore Node Battery"
state_topic: "v3/cubicore-environmental-node@ttn/devices/cubicore-devboard-node-01/up"
value_template: "{{ value_json.uplink_message.decoded_payload.battery_voltage }}"
unit_of_measurement: "V"
device_class: "voltage"
state_class: "measurement"

- name: "Cubicore Node LoRa RSSI"
state_topic: "v3/cubicore-environmental-node@ttn/devices/cubicore-devboard-node-01/up"
value_template: "{{ value_json.uplink_message.rx_metadata[0].rssi }}"
unit_of_measurement: "dBm"
device_class: "signal_strength"
state_class: "measurement"

10. Step 8: Troubleshooting & Diagnostic Matrix​

Symptom / ErrorRoot CauseResolution
Connection Refused: Not Authorized (rc=5)Username format missing @ttn or API key has insufficient permissions.Ensure username is formatted as <app-id>@ttn. Verify that the API Key has Read application traffic rights granted.
Connection Refused: Bad Username or Password (rc=4)Leading/trailing whitespace or truncated API key string.Regenerate the API key in the TTN Console and copy directly without whitespace.
Connection Times Out (Errno 110 / 60)Local network router or firewall blocking outbound TCP port 8883.Verify that port 8883 is open for outbound traffic. Test with plaintext port 1883 to rule out TLS firewall inspection.
decoded_payload is missing or emptyThe TTN Application Payload Formatter is unconfigured.Go to Payload formatters → Uplink in TTN Console and ensure the JavaScript decoder function from Chapter 6 is active.
No packets received on topic /upDevboard is failing OTAA join or transmitting on mismatched frequencies.Confirm Devboard serial monitor shows [RadioLib] Joined!. Confirm gateway has both 916.6 and 916.8 MHz channels online.
Downlinks not received by DevboardClass A timing window expired or node entered deep sleep immediately.In firmware, ensure you read node.sendReceive() before entering ESP32-S3 deep sleep.

Next Steps​