meshtastic-mqtt-client
Chat with a Meshtastic network from your computer, without a radio, through the MQTT broker your mesh's gateway node already talks to.
-
Desktop GUI:
- Chat with broadcast and private messages, including a delivery confirmation for private messages.
- A live node list showing battery, hops, last-heard time and position, plus a node map in your browser.
- Message search, and notifications for messages that arrive while the window is in the background.
-
Headless mode: prints mesh traffic to the terminal and records it. Useful on a server or Raspberry Pi.
-
Current Meshtastic protocol: works with firmware 2.x up to 2.8, including encrypted channels (default
AQ==key or your own PSK), the newer AES-CCM ("AEAD") channel encryption, and themsh/<region>/2/e/...topic layout. -
SQLite history: messages, nodes, positions and telemetry, exportable to CSV.
-
Duplicate filtering: the same packet arriving through several gateways is shown once.
-
Auto-reconnect: if the broker goes away, the client reconnects and resubscribes on its own.
-
Home Assistant bridge:
- Mesh sensor readings become Home Assistant entities.
- Automations can send alerts to the mesh and switch things on your sensor nodes.
- You can chat with Assist from the Meshtastic app.
- Bridge health is shown in Home Assistant.
It runs as a Docker container or a Home Assistant app. See Home Assistant.
-
Secrets: passwords and keys can live in
secrets.yamlor environment variables instead ofconfig.yaml.
How it works
radios ⇄ gateway node (MQTT uplink + downlink) ⇄ MQTT broker ⇄ this client
The client acts like another node on one channel. It decrypts what the gateway uplinks to the broker. It publishes your messages back, encrypted with the channel key, for the gateway to transmit over LoRa.
Setting up the gateway node
In the Meshtastic app, on the node connected to your broker:
- Settings → MQTT: enable it, and set the address, username, password and root topic of your broker.
- Settings → Channels → your channel: turn on Uplink enabled and Downlink enabled.
- Settings → LoRa: enable OK to MQTT.
Messages you send from this client reach the radios only through a gateway with downlink enabled. The public mqtt.meshtastic.org broker does not downlink to radios, so sending needs your own broker or your own gateway.
Installing
Python 3.10 or newer, with tkinter for the GUI. On Debian/Ubuntu: sudo apt install python3 python3-venv python3-tk.
git clone https://github.com/arankwende/meshtastic-mqtt-client
cd meshtastic-mqtt-client
python3 -m venv .venv
. .venv/bin/activate
pip install .
Configuring
cp config/config.example.yaml config/config.yaml
Then edit config/config.yaml:
mqtt:
host: 192.168.1.10
port: 1883
username: mesh
password: secret
tls: false
root_topic: msh/US # must match the gateway's root topic
meshtastic:
channel: LongFast # channel name, exactly as in the app
key: AQ== # channel PSK (base64) from the app; "" if unencrypted
aead: false # true if the channel uses AES-CCM ("AEAD") encryption
node_id: '!c0ffee01' # this client's id on the mesh: '!' + 8 hex digits
long_name: 'My Desktop'
short_name: 'DESK' # up to 4 characters
Config files from version 1 (with the uppercase MQTT: / MESHTASTIC: sections) still load, but you will probably need to add root_topic and key.
Running
meshtastic-mqtt-client # GUI, reads config/config.yaml
meshtastic-mqtt-client --headless # terminal only
meshtastic-mqtt-client -c /path/to/config.yaml --debug
From a checkout without installing, ./meshtastic-mqtt-client.py works the same way, once the dependencies are installed.
Data is written next to the config file:
| File | Contents |
|---|---|
meshtastic-mqtt-client.sqlite3 |
nodes, messages, positions, telemetry |
meshtastic-mqtt-client.log |
the log (rotated at 1 MB) |
Using the GUI
- Delivery confirmation. Private messages ask for an acknowledgement, and a line under each one says "✓ delivered" or "✗ not delivered". Retries are automatic.
- Node map. View → Map of nodes opens a map of every node that has shared its position, in your browser.
- Search. The Search tab finds old messages by their text.
- Notifications. While the window is in the background, the title shows how many messages are unread. Direct messages also ring the bell and, on Linux, show a desktop notification.
- Status bar. Shows how many packets arrived in the last hour, and through how many gateways.
- Export. File → Export messages and File → Export positions save CSV files.
Keeping secrets out of config.yaml
Any value in config.yaml can come from a secrets.yaml file next to it (the same convention as Home Assistant), or from an environment variable:
# config.yaml
mqtt:
password: !secret mqtt_password
meshtastic:
key: !secret channel_key
homeassistant:
mqtt:
password: !env HA_MQTT_PASSWORD
# secrets.yaml (chmod 600)
mqtt_password: s3cret
channel_key: 'base64 key from the app'
The client logs a warning if config.yaml is readable by every user on the machine.
Docker
mkdir config && cp config/config.example.yaml config/config.yaml # then edit it
docker build -t meshtastic-mqtt-client .
docker run -d --restart unless-stopped -v "$PWD/config:/config" meshtastic-mqtt-client
The container runs headless as an unprivileged user. It reads /config/config.yaml, and keeps its database and log in /config. docker-compose.example.yaml shows the same setup for Compose.
Home Assistant
The client can bridge your mesh to Home Assistant over MQTT. Everything appears automatically through MQTT Discovery.
| Feature | What you get |
|---|---|
| Sensors | Each listed mesh node becomes a device, with its telemetry, position and your own custom values. |
| Alerts | notify entities that send a message from any automation to your phone, or to the whole channel. |
| Controls | Switches and buttons that send commands to your own sensor nodes. |
| Message events | An event entity that fires for every message, so automations can react to "SOS" and similar. |
| Assist chat | DM the bridge from the Meshtastic app and Home Assistant's Assist answers. |
| Bridge health | Connection status, traffic and the outcome of the last message sent. |
Home Assistant needs the MQTT integration connected to the broker you configure below. Run the bridge in one of three ways:
- As a Home Assistant app (Home Assistant OS or Supervised). In Settings → Apps → App store → ⋮ → Repositories, add
https://github.com/arankwende/meshtastic-mqtt-client, then install Meshtastic Bridge. It picks up the Mosquitto broker app automatically. Its first start createsconfig.yamlin the app's configuration folder and stops so you can edit it. - In Docker, as described in Docker.
- Directly, with
meshtastic-mqtt-client --headless, for example as a systemd service.
Configuration
Add a homeassistant: section to config/config.yaml:
meshtastic:
channel: Home # a private channel: see "Security" below
key: 'paste the channel key from the app here'
node_id: '!c0ffee01'
long_name: 'Home Assistant'
short_name: 'HA'
homeassistant:
# Home Assistant's broker. Leave this out to use the broker in the mqtt: section.
mqtt:
host: 192.168.1.10
username: meshbridge
password: secret
nodes: ['!5e550001', '!5e550002'] # nodes that become Home Assistant devices
sensors: # optional presentation for custom values
tank: {unit_of_measurement: '%', icon: 'mdi:water'}
expire_after: 7200 # optional: unavailable after 2 h without data
assist:
enabled: true
allowed_nodes: ['!a1b2c3d4'] # your phone's node ID
max_requests_per_minute: 6
alerts:
nodes: ['!a1b2c3d4'] # default: the Assist allowed_nodes
channel: false # true adds a notify entity for the whole channel
controls:
'!5e550001': # must also be listed in nodes
- {key: pump, type: switch}
- {key: reset, type: button, name: Reset counter}
events: true # the "Mesh message" event entity
delivery:
retries: 2 # resends when a direct message isn't acknowledged
ack_timeout: 30 # seconds to wait for each acknowledgement
interval: 5 # seconds between consecutive messages
max_parts: 3 # long texts are split into at most this many parts
Assist, alerts and controls only work on a private channel. See Security.
Sensors
Only nodes listed in nodes become devices, so busy channels don't flood Home Assistant. Each device is named after the node's long name, and an entity appears the first time its value is reported.
Built-in telemetry. Battery, voltage, temperature, humidity, pressure, channel utilization and airtime come from the node's telemetry. You also get Last heard and Hops away. If the node shares its position, it gets a device_tracker that shows it on the map. A battery level of 101, which means "externally powered", is reported as 100.
To get readings from a sensor wired to a node (BME280, SHT31 and similar), open the node's settings in the Meshtastic app and enable Telemetry → Environment metrics, plus Position if you want location. Since firmware 2.8, both are off until you turn them on.
Custom values. You can send your own readings in either of two formats.
JSON on port 256 (PRIVATE_APP). Use this for your own code running on or next to a node:
import json
import meshtastic.serial_interface
iface = meshtastic.serial_interface.SerialInterface()
iface.sendData(json.dumps({"tank": 73, "pump_running": True}).encode(), channelIndex=1)
Set channelIndex to the index of the private channel on that node.
Text messages starting with #ha. Anything that can send a Meshtastic text can do this, including the phone app, which makes testing easy:
#ha tank=73 pump_running=on valve=open
Both formats follow the same rules:
- Numbers become sensors with long-term statistics.
true/falseandon/offbecome binary sensors.- Anything else becomes a text sensor.
- Keys are lowercased, characters other than
a-z,0-9and_become_, and keys are cut to 32 characters. - Each node can have at most 32 custom values.
Use the sensors: setting to give a key a unit, device class, state class, icon or name. Custom entities have IDs like sensor.water_tank_tank.
Assist chat
-
Create the automation. In Home Assistant, add this automation. In the UI, choose Settings → Automations → Create → Edit in YAML.
alias: Meshtastic → Assist mode: queued triggers: - trigger: mqtt topic: meshtastic-bridge/assist/request actions: - action: conversation.process data: text: "{{ trigger.payload_json.text }}" conversation_id: "mesh-{{ trigger.payload_json.from }}" agent_id: conversation.home_assistant # or your LLM conversation agent response_variable: result - action: mqtt.publish data: topic: meshtastic-bridge/assist/reply payload: >- {{ {"to": trigger.payload_json.from, "text": result.response.speech.plain.speech} | to_json }} -
Expose entities. Choose which entities Assist may control in Settings → Voice assistants → Expose. Assist ignores everything else, and replies with, for example, "Garage lights is not exposed".
-
Chat from your phone. In the Meshtastic app, open a direct message with the bridge node, for example "Home Assistant", and write "turn on the garage lights" or "what's the temperature in the tank?". The answer comes back as a direct message.
Replies are split into numbered parts of up to 200 bytes. Each part waits for your phone's acknowledgement and is resent if needed. With the default delivery settings that's at most 3 parts, and longer replies are cut with "…". If you use an LLM agent, ask it in its instructions to answer in one or two short sentences.
The bridge acknowledges your direct messages the way a radio does, so the Meshtastic app shows them as delivered.
Expect 5–30 seconds per round trip, depending on your preset and how many hops away you are.
Alerts
Every node in alerts.nodes gets a notify entity on the bridge device, such as notify.home_assistant_message_a1b2c3d4. With alerts.channel: true you also get one for the whole channel. Use them from any automation:
actions:
- action: notify.send_message
target:
entity_id: notify.home_assistant_message_a1b2c3d4
data:
message: "Water tank below 20%"
Direct alerts wait for an acknowledgement and are retried. The Last delivery sensor then shows delivered or failed. Channel alerts can't be acknowledged, so they show sent.
Controls
Controls let Home Assistant send commands to sensor nodes that run your own code.
- Switches. Each
switchcontrol becomes a switch on the node's device. Turning it on sends{"pump": true}to the node as a direct message on port 256, and turning it off sends{"pump": false}. - Buttons. Each
buttoncontrol sends{"reset": "press"}. - Delivery. Commands wait for an acknowledgement and are retried, like alerts.
The node's code should act on the command and then report the new state in its normal sensor data, for example {"pump": true}. The switch shows what the node reports, not what was requested, so it stays truthful if a command is lost. A switch whose node never reports its state shows unknown, unless you set optimistic: true on that control.
For example, with the Meshtastic Python library on a computer attached to the node:
import json
from pubsub import pub
import meshtastic.serial_interface
def on_command(packet, interface):
command = json.loads(packet["decoded"]["payload"])
if "pump" in command:
set_pump(command["pump"]) # your hardware code
interface.sendData(json.dumps({"pump": command["pump"]}).encode(), channelIndex=1)
pub.subscribe(on_command, "meshtastic.receive.data.PRIVATE_APP")
iface = meshtastic.serial_interface.SerialInterface()
Message events
The bridge device has an event entity called Mesh message. It fires channel_message for every broadcast on the channel, and direct_message for every direct message to the bridge. The event's attributes are:
from,nameandtext.hops_away.- The sender's last known
latitudeandlongitude. - For direct messages,
allowed: whether the sender is inassist.allowed_nodes.
triggers:
- trigger: state
entity_id: event.home_assistant_mesh_message
conditions:
- "{{ 'sos' in trigger.to_state.attributes.text | lower }}"
actions:
- action: notify.mobile_app_my_phone
data:
message: "SOS from {{ trigger.to_state.attributes.name }}"
#ha sensor messages don't fire events. Set events: false to remove the entity.
Bridge health
The bridge device updates these every minute:
- Mesh connection (connectivity): whether the bridge is connected to the mesh broker.
- Last packet: when anything was last heard on the channel.
- Packets last hour, Gateways last hour and Nodes heard (24 h).
- Gateway channel utilization: how busy the air is, as reported by your gateway node.
- Last delivery: the outcome of the most recent alert, Assist reply or control command (
delivered,sentorfailed). Its attributes hold the recipient, text, attempts and error. - Announce on mesh (button): broadcasts the bridge's node info.
If the bridge stops, all of its entities become unavailable within about 90 seconds, through its MQTT last will.
Security
Anyone who can get a message to Assist can control what Assist controls. The bridge enforces the following rules.
Checked at startup. The bridge refuses to start if any of these fail:
- If Assist, alerts or controls are enabled,
meshtastic.keyis a full 128- or 256-bit key, not the published default (AQ==or another one-byte shorthand) and not empty. Generate it in the Meshtastic app: Channels → your channel → Key → generate. With only sensors enabled, any channel works. homeassistant.mqttis not the public Meshtastic broker.assist.allowed_nodeslists at least one node. Neither it noralerts.nodescontains the bridge's ownnode_id.- Every node in
controlsis also listed innodes.
Checked for every incoming message. A message reaches Assist only if all of these hold:
- It was encrypted with your channel key. Packets on other channels or with other keys are never decrypted.
- It is a direct message to the bridge's node ID. Channel broadcasts never go to Assist.
- The sender's node ID is in
assist.allowed_nodes. Anything else is dropped without a reply, and logged. - The same packet has not been received before, checked against the message database so it holds across restarts. This stops someone from recording your encrypted "open the garage" and transmitting it again later.
- The sender is within
max_requests_per_minute. - It doesn't start with
#ha. Those messages are sensor data and are never sent to Assist.
Checked for every message to the mesh. These rules apply to everything the bridge sends:
- An Assist reply goes only to a node in
assist.allowed_nodes. - An alert goes only to a node in
alerts.nodes, or to the channel ifalerts.channelis on. - A control command goes only to a configured node and key. The switch payload must be
ONorOFF, and buttons only take a press. - Anything else is dropped and logged. Texts are capped at
delivery.max_partsparts.
What this does not protect against:
- Someone who has your channel key. Meshtastic channel messages don't authenticate the sender, so anyone with the key can send a message that claims to come from your phone's node ID. Treat the channel key like a password: share it only with devices you control, and change it if a device is lost.
- Someone who can write to your Home Assistant broker. They could publish to
meshtastic-bridge/assist/requestdirectly, bypassing the mesh. They could also make the bridge message your allowed nodes or send your configured control commands. Require a username and password on the broker, and use per-user ACLs if your broker supports them. - Lost commands. LoRa is unreliable, and a command can fail even after its retries. The Last delivery sensor and the switch's reported state show it. Don't rely on the mesh for anything safety-critical.
- Whatever Assist is allowed to do. The bridge can't make a command safe. Don't expose locks, alarms, garage doors or anything else you wouldn't want triggered by a stolen phone or a leaked key.
Checklist:
- The private channel uses a newly generated 256-bit key, and only on your own devices.
- The gateway has Uplink and Downlink enabled on that channel.
- Your broker requires a username and password, and is not reachable from the internet without TLS.
allowed_nodescontains only your own devices.- Only harmless entities are exposed to Assist.
MQTT topics
The base topic is meshtastic-bridge; change it with homeassistant.base_topic.
| Topic | Direction | Contents |
|---|---|---|
meshtastic-bridge/status |
bridge → HA | online / offline (last will), retained |
meshtastic-bridge/<node>/state |
bridge → HA | JSON with every value for the node, retained |
meshtastic-bridge/<node>/position |
bridge → HA | {"latitude": …, "longitude": …, "altitude": …}, retained |
meshtastic-bridge/assist/request |
bridge → HA | {"from": "!a1b2c3d4", "name": "…", "text": "…", "packet_id": …} |
meshtastic-bridge/assist/reply |
HA → bridge | {"to": "!a1b2c3d4", "text": "…"} |
meshtastic-bridge/notify/<node> or …/notify/channel |
HA → bridge | alert text |
meshtastic-bridge/<node>/set/<key> |
HA → bridge | ON / OFF for a switch control |
meshtastic-bridge/<node>/press/<key> |
HA → bridge | button control press |
meshtastic-bridge/bridge/state |
bridge → HA | health JSON, retained |
meshtastic-bridge/bridge/delivery |
bridge → HA | last delivery outcome, retained |
meshtastic-bridge/bridge/message |
bridge → HA | message events |
meshtastic-bridge/bridge/announce |
HA → bridge | announce button press |
homeassistant/<component>/meshtastic_<node>/<key>/config |
bridge → HA | Discovery configuration, retained |
<node> is the node ID without the !, for example 5e550001.
Home Assistant troubleshooting
- The automation never triggers. If you created it before the MQTT integration was set up, its trigger never attached. Turn the automation off and on again, or restart Home Assistant.
- Assist replies "… is not exposed". Expose the entity in Settings → Voice assistants → Expose.
- All entities are unavailable. The bridge is stopped or can't reach the broker. Entities come back as soon as it reconnects.
- No reply arrives on the phone. Check that the gateway has Downlink enabled on the channel, and that your phone's node ID is in
allowed_nodes. Run with--debugand look for "Ignoring Assist request". - Last delivery is always
failedwithTIMEOUT. The recipient's acknowledgements aren't reaching the broker. Check that your phone's own node has OK to MQTT enabled (Settings → LoRa), and that the gateway uplinks the channel. - A switch stays unknown. The node never reported the value. Have its code include the key in its sensor data, or set
optimistic: true.
Firmware compatibility
| Firmware | Status |
|---|---|
| 2.3 – 2.7 | Supported. |
| 2.8 (alpha) | Supported. 2.8 nodes sign their packets; this client reads signed packets but doesn't verify or add signatures. 2.8 nodes accept packets from nodes that have never signed, which includes this client. |
| AES-CCM channels | Supported. This is the use_aead channel option on the firmware development branch, not yet in a release. Incoming packets are recognised automatically. Set aead: true to send on such a channel. |
| 1.x | Not supported. Use version 1 of this project. |
Some 2.8 changes affect what you'll see:
- New default preset. New US nodes default to the LongTurbo preset instead of LongFast, and the two can't hear each other. Use the channel name your gateway shows.
- Position and telemetry are opt-in. Nodes on 2.8 only send position and telemetry when their owner turns it on, so expect fewer positions and battery readings.
- Coarse positions on public channels. On channels with known keys, such as the default, 2.8 nodes share only approximate positions. Precise positions need a private channel.
Troubleshooting
- Connected, but nothing arrives. Check that
root_topicandchannelmatch the gateway exactly; they are case-sensitive. A US gateway set up on firmware 2.8 is probably onLongTurbo. Use--debugto see packets skipped for using another channel or key. - "Connection refused: Not authorized". The MQTT username or password is wrong.
- Others don't see my messages. The gateway needs Downlink enabled on that channel, and the broker must be the one the gateway uses.
- Direct messages from phones don't show up. Since firmware 2.5, DMs between nodes that know each other's public keys use end-to-end (PKI) encryption, which the channel key cannot decrypt. Channel messages always work.
Development
pip install -e ".[test]"
pytest
The code lives in meshtastic_mqtt_client/:
| Module | Role |
|---|---|
config.py |
YAML loading and validation |
crypto.py |
channel key expansion, AES-CTR, channel hash |
protocol.py |
ServiceEnvelope decoding and encoding (pure functions) |
storage.py |
SQLite |
client.py |
MQTT connection, deduplication, sending |
display.py |
text formatting |
gui.py |
FreeSimpleGUI window |
cli.py |
command line and headless mode |
homeassistant.py |
Home Assistant bridge: Discovery, sensors, alerts, controls, events, health, Assist |
mapview.py |
the browser node map |
Protobuf definitions come from the official meshtastic package, so they stay current with the firmware.
The decoding was originally based on Josh Pirihi's meshtastic-mqtt.