Ingesting Telemetry via TTN MQTT
Applicable to the Cubicore Devboard (ESP32-S3 + SX1262 on RAK3112 module) provisioned with The Things Network (TTN).
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 / Capability | HTTP Webhooks (Cubicore Cloud) | TTN MQTT Broker |
|---|---|---|
| Ingress Requirement | Requires a public HTTPS endpoint with a valid SSL/TLS certificate. | Outbound TCP/TLS connection only. Works seamlessly behind NAT, home routers, and firewalls. |
| Connection Model | Stateless HTTP POST requests dispatched per uplink packet. | Persistent, stateful TCP socket with automatic keep-alive pings. |
| Bidirectional Support | Primarily 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 Cases | Managed 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
- Cubicore Devboard Node (ESP32-S3 + SX1262): Samples environmental sensors and broadcasts encrypted LoRaWAN packets over sub-GHz radio (
916.6 / 916.8 MHz). - Cubicore Gateway Pocket / Hub: Demodulates radio frames and forwards UDP packets over local Wi-Fi or Ethernet backhaul to the cloud.
- 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
POSTrequests 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).
- HTTP Webhook Pipeline: Dispatches stateless JSON
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 Region | Primary Geography | MQTT Broker Hostname | Plaintext Port | TLS/SSL Port (Recommended) |
|---|---|---|---|---|
Australia / Southeast Asia (au1) | Philippines, Asia-Pacific, Australia | au1.cloud.thethings.network | 1883 | 8883 |
Europe (eu1) | Europe, Africa, Middle East | eu1.cloud.thethings.network | 1883 | 8883 |
North America (nam1) | United States, Canada, Americas | nam1.cloud.thethings.network | 1883 | 8883 |
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:
- Username: Your TTN Application ID followed by the
@ttnsuffix (e.g.,cubicore-environmental-node@ttn). - Password: An API Key generated with explicit traffic access rights.
Step-by-Step Procedure
- Sign in to your The Things Network Console.
- Select your cluster (e.g., Australia 1 or Europe 1).
- In the top navigation, click Applications and open your registered Devboard application.
- In the left-hand sidebar, navigate to Integrations → MQTT.
- 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
- Public TLS address:
- Under the API keys section, click Generate API key.
- 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).
- Name: Enter a descriptive identifier (e.g.,
- Click Generate API key.
- Copy the generated API key immediately and store it in a secure location. TTN will never display this key again after closing the modal.
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.
Uplink Topics (Node to Application)
| Topic Pattern | Description |
|---|---|
v3/<app-id>@ttn/devices/<device-id>/up | Receives uplink telemetry packets from a specific Devboard node. |
v3/<app-id>@ttn/devices/+/up | Wildcard subscription: receives uplinks from all registered devices in the application. |
v3/<app-id>@ttn/devices/<device-id>/join | Emits an event whenever a Devboard successfully negotiates an OTAA activation handshake. |
Downlink Topics (Application to Node)
| Topic Pattern | Description |
|---|---|
v3/<app-id>@ttn/devices/<device-id>/down/push | Target topic used by your client to enqueue a downlink command for transmission. |
v3/<app-id>@ttn/devices/<device-id>/down/queued | Emits notification when a downlink message is accepted into TTN's outbound queue. |
v3/<app-id>@ttn/devices/<device-id>/down/sent | Emits notification when the gateway transmits the downlink packet over the airwaves. |
v3/<app-id>@ttn/devices/<device-id>/down/ack | Emits confirmation when the node acknowledges receipt of a confirmed downlink. |
5. Step 3: Deconstructing the Uplink JSON Payload
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
uplink_message.decoded_payload: Contains clean, human-readable JSON fields produced by the JavaScript Payload Formatter configured in TTN.uplink_message.f_cnt: Frame counter. Increments sequentially with each uplink; vital for detecting dropped packets.uplink_message.rx_metadata[0].rssi: Received Signal Strength Indicator in dBm. Values between-50 dBmand-90 dBmindicate strong links; below-120 dBmindicates marginal signal.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.uplink_message.settings.frequency: Confirm that incoming packets match the Philippine AS923-3 dual channels (916600000or916800000Hz).
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
Subscribing to Live Uplinks
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()
8. Step 6: Publishing Downlink Commands (Bidirectional Control)
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.
Understanding Class A Downlink Timing
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:
- When you publish an MQTT downlink to TTN, TTN holds the message in an outbound FIFO queue.
- When the Devboard transmits its next uplink, it opens two brief receive windows (RX1 at 1s and RX2 at 2s).
- 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
Downlink JSON Schema
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 hex0x01(e.g., Turn ON Relay / LED). Hex0x00encoded as Base64 is"AA==".confirmed: Set tofalsefor unconfirmed commands (recommended) ortrueif you require cryptographic ACK verification.
Python Downlink Script (cubicore_downlink.py)
#!/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)
Devboard Firmware Downlink Reception (RadioLib)
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 / Error | Root Cause | Resolution |
|---|---|---|
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 empty | The 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 /up | Devboard 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 Devboard | Class 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
- Integrating with Cubicore Cloud — Set up automated HTTP webhooks to visualize live metrics on the Cubicore Cloud SaaS platform.
- Power and Battery Optimization — Optimize radio duty cycles and utilize ESP32-S3 deep sleep to run untethered on battery power for months.
- LoRa Point-to-Point (P2P) — Explore direct Devboard-to-Devboard raw LoRa transmission without network servers.