In Meshtastic there are two main variants of the hardware: NRF and ESP32. Each have their own pluses and minuses. The most common devices such as RAK and Seeed are NRF. They are very low power, which means with the proper power settings you can get a week or so on a single charge. They also have more production quality devices instead of just development boards. Due to this they don’t support sending to MQTT in JSON. They only send in the encrypted Meshtastic format. That makes using them as a tracker inside Home Assistant or a TIG stack impossible unless you decode the payload.

ESP32 devices can be configured to send encrypted or JSON. I have a Python script that pulls the encrypted data from a topic, decrypts it, and publishes to another unencrypted topic. This is a work in progress, but it will let you see the payload.

I use Ubuntu Linux, so the instructions are based on that.

Create the Python script

Ensure Python is installed, make a directory, then save this as meshtastic_nrftojson.py.

Pythonmeshtastic_nrftojson.py
import json
import base64
import paho.mqtt.client as mqtt
from paho.mqtt.client import CallbackAPIVersion
from meshtastic import mqtt_pb2, mesh_pb2, telemetry_pb2
from google.protobuf.json_format import MessageToDict
from Crypto.Cipher import AES
from Crypto.Util import Counter

MQTT_HOST = "10.10.10.10"
MQTT_PORT = 1883
MQTT_USER = "mqttuser"
MQTT_PASS = "newpassword"
MQTT_TOPIC = "msh/US/2/e/LongFast/!8a35dfcc"
DESTINATION_TOPIC = "msh/US/2/json/nrfjson"
PSK_BASE64 = "AQ=="
CHANNEL_KEY = base64.b64decode(PSK_BASE64)

def decrypt_packet(encrypted_bytes, key, from_id, packet_id):
    nonce = packet_id.to_bytes(4, "little") + from_id.to_bytes(4, "little") + b"\x00" * 8
    ctr = Counter.new(128, initial_value=int.from_bytes(nonce, "big"), little_endian=False)
    cipher = AES.new(key, AES.MODE_CTR, counter=ctr)
    return cipher.decrypt(encrypted_bytes)

def on_connect(client, userdata, flags, reason_code, properties):
    if reason_code == 0:
        print(f"Connected to {MQTT_HOST}. Bridging nRF52 data...")
        client.subscribe(MQTT_TOPIC)

def on_message(client, userdata, msg):
    try:
        envelope = mqtt_pb2.ServiceEnvelope()
        envelope.ParseFromString(msg.payload)
        packet = envelope.packet
        from_id = getattr(packet, "from_", 0)
        packet_id = getattr(packet, "id", 0)

        if packet.encrypted:
            try:
                decrypted_data = decrypt_packet(packet.encrypted, CHANNEL_KEY, from_id, packet_id)
                packet.decoded.ParseFromString(decrypted_data)
            except Exception:
                pass

        packet_dict = MessageToDict(
            packet,
            preserving_proto_field_name=True,
            always_print_fields_with_no_presence=True,
        )
        decoded_payload = packet_dict.get("decoded", {})
        portnum = decoded_payload.get("portnum")

        if portnum == "POSITION_APP":
            pos = mesh_pb2.Position()
            pos.ParseFromString(packet.decoded.payload)
            decoded_payload["latitude"] = pos.latitude_i / 1e7 if pos.latitude_i else None
            decoded_payload["longitude"] = pos.longitude_i / 1e7 if pos.longitude_i else None
            decoded_payload["altitude"] = pos.altitude if pos.altitude else None
        elif portnum == "TEXT_MESSAGE_APP":
            raw_b64 = decoded_payload.get("payload", "")
            if raw_b64:
                decoded_payload["text"] = base64.b64decode(raw_b64).decode("utf-8", errors="ignore")

        output = {
            "from": from_id,
            "to": packet_dict.get("to", 0),
            "type": "packet",
            "payload": decoded_payload,
            "rssi": packet_dict.get("rx_rssi", 0),
            "snr": packet_dict.get("rx_snr", 0),
        }
        client.publish(DESTINATION_TOPIC, payload=json.dumps(output), qos=1)
        print(f"Published decoded packet from {from_id} to {DESTINATION_TOPIC}")
    except Exception as error:
        print(f"Bridge Error: {error}")

def main():
    client = mqtt.Client(CallbackAPIVersion.VERSION2)
    if MQTT_USER:
        client.username_pw_set(MQTT_USER, MQTT_PASS)
    client.on_connect = on_connect
    client.on_message = on_message
    client.connect(MQTT_HOST, MQTT_PORT, 60)
    client.loop_forever()

if __name__ == "__main__":
    main()

Change the MQTT host, user, password, encrypted topic, destination topic, and PSK to match your mesh. AQ== is the public LongFast key.

Set up the environment

Bashsetup.sh
sudo apt update
python3 -m venv mesh-venv
source mesh-venv/bin/activate
pip install paho-mqtt meshtastic pycryptodome
python3 meshtastic_nrftojson.py

Then run it as a service so it survives reboot.

systemdmeshtastic-nrftojson.service
[Unit]
Description=Meshtastic nRF52 MQTT to JSON Bridge
After=network.target

[Service]
ExecStart=/scripts/mesh-venv/bin/python3 -u /scripts/meshtastic_nrftojson.py
WorkingDirectory=/scripts
Restart=always
User=root
Environment=PYTHONUNBUFFERED=1

[Install]
WantedBy=multi-user.target
Bashenable-service.sh
sudo systemctl daemon-reload
sudo systemctl enable meshtastic-nrftojson.service
sudo systemctl start meshtastic-nrftojson.service

This service is for a single topic, typically the primary channel that carries telemetry and GPS. Secondary channels need another script and another unit.