~/jackwent.co.uk
jack@jackwent:~$ cat meshtastic-templates.yaml

Meshtastic in Home Assistant: A Roof Antenna Node and a Mesh Dashboard

by published 2 November 20267 min read

Meshtastic turns cheap LoRa radio boards into a text messaging mesh. Each node passes messages on for the others, so they travel for miles with no mobile signal and no internet. I’ve got one node running full time in the loft with its antenna on the roof, and everything it hears goes into Home Assistant: my node’s health, a table of the nodes around me, and the mesh chat.

At a glance
RadioHeltec V3 style board (ESP32 and SX1262 LoRa), 868 MHz for the UK
WhereMy loft comms rack, with the antenna on the roof
Into Home AssistantThe node’s own MQTT module, sending JSON to the Mosquitto broker
In Home AssistantBattery, voltage, channel use, airtime, uptime, last packet, nodes heard, node names, chat
NeedsHome Assistant with Mosquitto. No custom integration
Configmeshtastic-templates.yaml and meshtastic-dashboard.yaml

Why Meshtastic

Partly it’s a backup. If mobile signal or the internet goes down, a LoRa mesh still carries messages on its own. Partly I wanted to play with LoRa, antennas and coax. There’s already an active mesh around London and the South East, so a node with a roof antenna has plenty to talk to. And I wanted the mesh in Home Assistant alongside everything else, the same way my servers are.

The Hardware

Everything I used:

PartWhat it doesWhere from
ESP32 LoRa V3 board kit (Heltec V3 design, SX1262 radio, 868 MHz), with a case, 1100mAh battery, small antenna and USB cableThe Meshtastic node itselfAmazon
868 MHz omni soft whip antenna, 17 cm, SMA male, listed as 10 dBiThe antenna on the roofAmazon
TUOLNK RG174 coax, 3 m, SMA male to female bulkhead, with 3 SMA adaptersRuns from the board in the loft up to the antennaAmazon
A long USB-C cable and a generic USB plugPowerAnything you have spare

868 MHz is the band Meshtastic uses in the UK and Europe, so buy the 868 MHz versions of the board and antenna.

The board lives in the comms rack in my loft, powered over the long USB-C cable. The coax runs from the board through a bulkhead fitting to the whip antenna on the roof outside.

comms-rack.jpg
A small black wall mounted comms cabinet with a glass door on the bare brick wall of a loft, with network kit, a NAS, a mini PC and a surge protected power strip inside and cables running out of the bottom, with the equipment labels blurred
The comms rack in my loft. The Meshtastic board is in there, out of sight behind the other kit. I've blurred the labels.

Thin coax like RG174 loses some signal over every metre at 868 MHz, so it’s worth keeping the run as short as the job allows.

Flashing it

I flashed Meshtastic with the web flasher. Plug the board in over USB, open the flasher in Chrome or Edge, and its auto detect picks out the board. Choose a firmware version, flash it, and the board comes up as a Meshtastic node ready to set up from the app. There’s no toolchain to install.

Getting the Mesh into Home Assistant

Meshtastic has an MQTT module built in. I pointed it at the Mosquitto broker that already runs in Home Assistant for my server agent and Zigbee2MQTT, and turned on JSON output. Now every packet my node hears, from any node on the mesh, arrives in Home Assistant as a small JSON message, about 600 a day:

msh/EU_868/England/2/json/LongFast/!a1b2c3d4
{"from": 1205151091, "sender": "!a1b2c3d4", "type": "telemetry", "hops_away": 3,
 "snr": -14, "rssi": -110, "payload": {"temperature": 19.5, "relative_humidity": 44.9, ...}}

JSON output is only available on ESP32 based boards, so check yours before you buy. The settings that matter on the node are:

  • MQTT enabled, with your Home Assistant address and an MQTT user.
  • JSON output enabled.
  • Root topic: mine is msh/EU_868/England, and it has to match the topics in the config below.
  • Uplink enabled on the LongFast channel.
  • TLS enabled if your broker has a certificate. Mosquitto in Home Assistant listens for encrypted MQTT on port 8883 once you give it one, and I covered setting that up in the server agent post.

Everything after that is template sensors, with no custom integration and nothing from HACS.

The Catch: Whose Battery Is That?

My first version had a battery sensor that read the battery level from any telemetry packet. It looked fine until I counted the readings. In one day the sensor took 164 battery readings, and only 58 of them were from my node. The other 106 came from nine other nodes on the mesh, so “my” battery was really whichever node had reported last, anywhere from 81% to 100%.

Each JSON message has two fields that sort this out. from is the node that sent the packet, and sender is the node that uplinked it to MQTT, which is always mine. When the two match, the packet is my node’s own:

{{ ('!%08x' | format(p.from | int)) == p.sender }}

Every sensor about my node now checks that first. Because of that, the config has no node ID in it and works for anyone’s node without changes.

What Home Assistant Shows

meshtastic-dashboard.png
The Meshtastic dashboard in Home Assistant: tiles for last packet, battery, voltage, uptime, channel use and airtime, 48 hour graphs of battery and of channel use and airtime, a table of 18 nodes heard with their hops, SNR, RSSI and minutes since last heard, and a messages table, with every node name blurred
My Meshtastic dashboard in Home Assistant. I've blurred the node names, mine included. The two drops in the battery graph date from before the fix below, when any node's battery report could land in my sensor.
EntityWhat it shows
Battery and VoltageMy node only. Meshtastic reports 101% on external power, so the sensor shows 100% and flags it
Channel Utilisation and Air Util TXHow busy the channel is, and how much of the airtime my node uses
UptimeHours since the node last restarted
Last PacketWhen anything last came through. If this goes stale, the node or its uplink has stopped
Nodes HeardNodes heard in the last 24 hours, with name, hops, signal and when
Node RegistryEvery node name seen, so messages show names instead of numbers. Mine knows 568
Last Message and Chat LogThe latest message, and the last 20 with time and sender

The dashboard has three sections. My node has status tiles and 48 hour graphs of battery, channel use and airtime. Mesh has a table of the 25 most recently heard nodes with their hops, SNR and RSSI. Messages has the chat as a table.

The SNR and RSSI in that table are for the last hop into my node, not from the node that sent the message. For a node three hops away, it’s the signal from whichever node passed it on to me. It’s still a good guide to which nearby nodes my roof antenna hears well.

How I Use It

Mostly I glance at Last Packet and Uptime to check the node is running and still talking to Home Assistant. The node table shows who’s around, how many hops away and how well I hear them, and I can read the mesh chat without opening the Meshtastic app. The graphs show how busy the channel gets over a day and how much of that is my node.

The Config

This is the exact config running in my Home Assistant. It doesn’t need node IDs, addresses or passwords, so I haven’t had to remove anything from it. Both files are below to read and download. To set it up:

  1. Set up the node’s MQTT as above, and check packets arrive (in Home Assistant, Settings › Devices & services › MQTT › Configure, listen to msh/#).
  2. Make sure configuration.yaml has template: !include templates.yaml, then add the sensors to templates.yaml. If your root topic isn’t msh/EU_868/England, change the five topic: lines.
  3. Check the configuration, then reload Template entities under Developer tools › YAML.
  4. Add a view to a dashboard, choose Edit in YAML and paste in the dashboard file.

The sensors fill in as packets arrive. Node names build up from the nodeinfo packets nodes send every few hours, and the battery updates when your node sends its own telemetry.

meshtastic-templates.yaml (the sensors)
# -------------------------------------------------
# MESHTASTIC
# Your node uplinks everything it hears as JSON to msh/EU_868/England/2/json/LongFast/<node>.
# Each packet's "sender" is the uplinking node (yours) and "from" is the node that sent it, so a
# packet is your node's own when "!%08x" of from equals sender. Other nodes' telemetry is ignored
# for your node's sensors, so they never show someone else's battery.
# If your node uses a different region or root topic, change the 5 "topic:" lines.
# -------------------------------------------------

# Your node's own device telemetry: battery, voltage, channel use, uptime.
- trigger:
    - platform: mqtt
      topic: "msh/EU_868/England/2/json/LongFast/+"
  condition:
    - condition: template
      value_template: >
        {% set p = trigger.payload_json %}
        {{ p is mapping and p.type == 'telemetry' and p.from is defined and p.sender is defined
           and ('!%08x' | format(p.from | int)) == p.sender
           and p.payload is mapping and p.payload.voltage is defined }}
  sensor:
    - name: "Meshtastic Battery Voltage"
      unique_id: meshtastic_own_battery_voltage
      device_class: voltage
      state_class: measurement
      unit_of_measurement: "V"
      state: >
        {% set v = (trigger.payload_json | default({})).payload | default({}) %}
        {{ v.voltage | float | round(2) if v.voltage is defined else this.state }}
    - name: "Meshtastic Battery Percent"
      unique_id: meshtastic_own_battery_percent
      device_class: battery
      state_class: measurement
      unit_of_measurement: "%"
      # Meshtastic reports 101 when it's running on external power.
      state: >
        {% set v = (trigger.payload_json | default({})).payload | default({}) %}
        {{ [v.battery_level | int, 100] | min if v.battery_level is defined else this.state }}
      attributes:
        external_power: "{{ (((trigger.payload_json | default({})).payload | default({})).battery_level | default(0) | int) > 100 }}"
    - name: "Meshtastic Channel Utilisation"
      unique_id: meshtastic_own_channel_utilisation
      state_class: measurement
      unit_of_measurement: "%"
      icon: mdi:radio-tower
      state: >
        {% set v = (trigger.payload_json | default({})).payload | default({}) %}
        {{ v.channel_utilization | float | round(1) if v.channel_utilization is defined else this.state }}
    - name: "Meshtastic Air Util TX"
      unique_id: meshtastic_own_air_util_tx
      state_class: measurement
      unit_of_measurement: "%"
      icon: mdi:access-point
      state: >
        {% set v = (trigger.payload_json | default({})).payload | default({}) %}
        {{ v.air_util_tx | float | round(2) if v.air_util_tx is defined else this.state }}
    - name: "Meshtastic Uptime"
      unique_id: meshtastic_own_uptime
      device_class: duration
      unit_of_measurement: "h"
      state: >
        {% set v = (trigger.payload_json | default({})).payload | default({}) %}
        {{ (v.uptime_seconds | int / 3600) | round(1) if v.uptime_seconds is defined else this.state }}

# When anything last came through from your node: the uplink is working.
- trigger:
    - platform: mqtt
      topic: "msh/EU_868/England/2/json/LongFast/+"
  sensor:
    - name: "Meshtastic Last Packet"
      unique_id: meshtastic_last_packet
      device_class: timestamp
      icon: mdi:access-point-network
      state: "{{ now().isoformat() }}"
      attributes:
        type: "{{ trigger.payload_json.type | default('') }}"

# Names of every node ever heard (from nodeinfo packets), so messages show a name, not a number.
- trigger:
    - platform: mqtt
      topic: "msh/EU_868/England/2/json/LongFast/+"
  condition:
    - condition: template
      value_template: >
        {% set p = trigger.payload_json %}
        {{ p is mapping and p.type == 'nodeinfo' and p.from is defined and p.payload is mapping }}
  sensor:
    - name: "Meshtastic Node Registry"
      unique_id: meshtastic_node_registry
      icon: mdi:account-network
      state: "{{ (state_attr('sensor.meshtastic_node_registry', 'nodes') or {}) | count }}"
      attributes:
        nodes: >
          {% set p = trigger.payload_json %}
          {% set old = state_attr('sensor.meshtastic_node_registry', 'nodes') or {} %}
          {% set name = p.payload.longname | default(p.payload.shortname | default(p.payload.id | default(p.from))) %}
          {{ dict(old, **{(p.from | string): name}) }}

# Nodes heard in the last 24 hours: name, hardware, when, hops and the last hop's signal (into your node).
- trigger:
    - platform: mqtt
      topic: "msh/EU_868/England/2/json/LongFast/+"
  condition:
    - condition: template
      value_template: "{{ trigger.payload_json is mapping and trigger.payload_json.from is defined }}"
  sensor:
    - name: "Meshtastic Nodes Heard"
      unique_id: meshtastic_nodes_heard
      icon: mdi:radar
      unit_of_measurement: "nodes"
      state_class: measurement
      state: >
        {% set old = state_attr('sensor.meshtastic_nodes_heard', 'nodes') or {} %}
        {% set cutoff = as_timestamp(now()) - 86400 %}
        {% set ns = namespace(n=0) %}
        {% for k, v in old.items() if v is mapping and v.last > cutoff and k != (trigger.payload_json.from | string) %}
          {% set ns.n = ns.n + 1 %}
        {% endfor %}
        {{ ns.n + 1 }}
      attributes:
        nodes: >
          {% set p = trigger.payload_json %}
          {% set t = as_timestamp(now()) | int %}
          {% set id = p.from | string %}
          {% set old = state_attr('sensor.meshtastic_nodes_heard', 'nodes') or {} %}
          {% set prev = old.get(id) if old.get(id) is mapping else {} %}
          {% set names = state_attr('sensor.meshtastic_node_registry', 'nodes') or {} %}
          {% set info = p.payload if p.type == 'nodeinfo' and p.payload is mapping else {} %}
          {% set rec = {
               'name': info.longname | default(names.get(id, prev.name | default('!%08x' | format(p.from | int)))),
               'hw': info.hardware | default(prev.hw | default(none)),
               'last': t,
               'hops': p.hops_away | default(prev.hops | default(none)),
               'snr': p.snr | default(prev.snr | default(none)),
               'rssi': p.rssi | default(prev.rssi | default(none)),
             } %}
          {% set ns = namespace(out={}) %}
          {% for k, v in old.items() if v is mapping and v.last > t - 86400 %}
            {% set ns.out = dict(ns.out, **{k: v}) %}
          {% endfor %}
          {{ dict(ns.out, **{id: rec}) }}

# Text messages on LongFast: the latest one, and the last 20 with time and sender.
- trigger:
    - platform: mqtt
      topic: "msh/EU_868/England/2/json/LongFast/+"
  condition:
    - condition: template
      value_template: >
        {% set p = trigger.payload_json %}
        {{ p is mapping and p.type == 'text' and p.payload is mapping and p.payload.text is defined }}
  sensor:
    - name: "Meshtastic Last Message"
      unique_id: meshtastic_last_message
      icon: mdi:message-text
      state: >
        {% set p = trigger.payload_json %}
        {% set sender = (state_attr('sensor.meshtastic_node_registry', 'nodes') or {}).get(p.from | string, '!%08x' | format(p.from | int)) %}
        {{ (sender ~ ': ' ~ p.payload.text)[:250] }}
      attributes:
        sender: >
          {{ (state_attr('sensor.meshtastic_node_registry', 'nodes') or {}).get(trigger.payload_json.from | string, '!%08x' | format(trigger.payload_json.from | int)) }}
        message: "{{ trigger.payload_json.payload.text }}"
    - name: "Meshtastic Chat Log"
      unique_id: meshtastic_chat_log
      icon: mdi:message-badge
      # State: when the last message arrived (the messages themselves are in the attribute).
      state: "{{ now().strftime('%d %b %H:%M') }}"
      attributes:
        messages: >
          {% set p = trigger.payload_json %}
          {% set sender = (state_attr('sensor.meshtastic_node_registry', 'nodes') or {}).get(p.from | string, '!%08x' | format(p.from | int)) %}
          {% set old = state_attr('sensor.meshtastic_chat_log', 'messages') %}
          {% set old = old if old is list else [] %}
          {{ ([{'time': now().strftime('%d %b %H:%M'), 'from': sender, 'hops': p.hops_away | default(none),
                'text': p.payload.text[:200]}] + old)[:20] }}
meshtastic-dashboard.yaml (the dashboard view)
type: sections
max_columns: 4
title: Meshtastic
path: meshtastic
sections:
- type: grid
  cards:
  - type: heading
    heading: My node
    heading_style: title
    icon: mdi:access-point
  - type: tile
    entity: sensor.meshtastic_last_packet
    name: Last packet
  - type: tile
    entity: sensor.meshtastic_battery_percent
    name: Battery
  - type: tile
    entity: sensor.meshtastic_battery_voltage
    name: Voltage
  - type: tile
    entity: sensor.meshtastic_uptime
    name: Uptime
  - type: tile
    entity: sensor.meshtastic_channel_utilisation
    name: Channel use
  - type: tile
    entity: sensor.meshtastic_air_util_tx
    name: Airtime TX
  - type: history-graph
    title: Battery
    hours_to_show: 48
    entities:
    - entity: sensor.meshtastic_battery_percent
      name: Battery
    grid_options:
      columns: 12
  - type: history-graph
    title: Channel use and airtime
    hours_to_show: 48
    entities:
    - entity: sensor.meshtastic_channel_utilisation
      name: Channel use
    - entity: sensor.meshtastic_air_util_tx
      name: Airtime TX
    grid_options:
      columns: 12
- type: grid
  cards:
  - type: heading
    heading: Mesh
    heading_style: title
    icon: mdi:radar
  - type: tile
    entity: sensor.meshtastic_nodes_heard
    name: Nodes heard (24h)
  - type: tile
    entity: sensor.meshtastic_node_registry
    name: Nodes known
  - type: markdown
    content: |-
      {% set nodes = state_attr('sensor.meshtastic_nodes_heard', 'nodes') or {} %}
      {% set ns = namespace(rows=[]) %}
      {% for k, v in nodes.items() if v is mapping %}{% set ns.rows = ns.rows + [v] %}{% endfor %}
      {% if ns.rows %}
      | Node | Hops | SNR | RSSI | Heard |
      |:--|:-:|--:|--:|--:|
      {% for v in (ns.rows | sort(attribute='last', reverse=true))[:25] -%}
      | {{ v.name | replace('|', '/') }} | {{ v.hops if v.hops is not none else '?' }} | {{ v.snr if v.snr is not none else '' }} | {{ v.rssi if v.rssi is not none else '' }} | {{ ((as_timestamp(now()) - v.last) / 60) | int }} min ago |
      {% endfor %}
      {% if ns.rows | count > 25 %}_and {{ ns.rows | count - 25 }} more_{% endif %}
      _SNR and RSSI are for the last hop into my node, not from the sender itself._
      {% else %}No nodes heard yet.{% endif %}
    grid_options:
      columns: 12
- type: grid
  cards:
  - type: heading
    heading: Messages
    heading_style: title
    icon: mdi:message-text
  - type: tile
    entity: sensor.meshtastic_last_message
    name: Last message
    vertical: false
    grid_options:
      columns: 12
      rows: 1
  - type: markdown
    content: |-
      {% set m = state_attr('sensor.meshtastic_chat_log', 'messages') or [] %}
      {% if m %}
      | When | From | Message |
      |:--|:--|:--|
      {% for x in m -%}
      | {{ x.time }} | {{ x['from'] | replace('|', '/') }} | {{ x.text | replace('|', '/') | replace('\n', ' ') }} |
      {% endfor %}
      {% else %}No messages since the log was set up.{% endif %}
    grid_options:
      columns: 12

The name list only ever grows. Once it holds several hundred nodes, Home Assistant may log that the sensor’s attributes are too big to keep in history. That only affects history, and the names still work.

Final Thoughts

The hardware is a small board, a USB cable, 3 metres of coax and an antenna on the roof. If you already run Mosquitto, the Home Assistant side is two YAML files. For more on how the rest of my smart home fits together, see the rules my smart home lives by.

Tags: Meshtastic, LoRa, Home Assistant, MQTT, Mosquitto, Radio

Was this helpful?

[jackwent]