Real-time Meshtastic mesh network monitoring
NodePulse is a Home Assistant addon and custom integration that gives you deep visibility into your Meshtastic mesh network node health, signal metrics, GPS positions on the HA map, packet inspection, and encrypted direct messaging all from inside Home Assistant.
| Feature | Description |
|---|---|
| 🟢 Connection Status | Binary sensor — know immediately if your mesh link drops |
| 📡 Node Count | Live count of all visible mesh nodes |
| 📶 Per-Node Metrics | SNR, hops away, battery level, last heard — one HA device per node (RSSI is reported by the firmware as "Not provided" where unavailable) |
| 🗺️ GPS Mapping | Device trackers plotted on the native HA map card |
| 🌡️ Coverage Heatmap | Visual heatmap layer on the map showing signal strength (SNR) with dynamic gradient legend |
| 🕸️ Network Topology | Force-directed network graph visualizing nodes, roles, and connections (traceroutes & neighbors) with SNR coloring. Includes interactive toggles for node names, edges, physics, and a node search box |
| 💬 Messaging | Send broadcast or DM messages via the Web UI; channel tabs appear immediately with real channel names, and the chat shows each sender's short name |
| 🔍 Traceroute | Dispatch traceroutes to any node from the Web UI (fire-and-forget — results appear on the next poll) |
| 🖥️ Web UI Dashboard | Full-featured dashboard served via HA Ingress (no port forwarding). PWA-ready and fully mobile-optimized with slide-in navigation, responsive data tables, and tap-zoom protection. |
| 📦 Packet Inspector | Real-time packet capture ring buffer showing every inbound Meshtastic packet with portnum, source/destination (with short names), channel, SNR, hop count, ACK status, and expandable JSON detail. Sort/filter by column headers, export to JSON/CSV, and view live sniffer stats. Fully responsive on mobile screens. |
| 📨 Notify Platform | notify.mesh_<entry> entity — send mesh messages from any automation/script, plus one notify.mesh_<entry>_channel_<name> entity per configured channel |
| ⚡ Service Actions | nodepulse.send_message, nodepulse.request_position, nodepulse.trace_route |
| 🤖 Device Triggers & Actions | Automate on message received/sent (and channel_message.received); send message / request position / trace route per node device |
| 📜 Logbook | Mesh messages recorded in the Home Assistant logbook timeline |
| 🗂️ Persistent Node Store | Every node ever seen is saved and re-shown even after the radio drops it from its bounded (~250) node DB; evicted nodes appear faded ("cached") and keep their last-known GPS position |
| 📍 Last-Known-Position Retention | Nodes that lose GPS or stop reporting keep their previous good fix on the map instead of vanishing; last_position_fix exposed per node |
| 🔎 Map Node Filter | Filter the map by name/ID, max hops away, last-heard window, or cached-only — with a live node count |
| 🏷️ Node Tagging | Comma-separated tags per node stored server-side; visible on node cards |
| 🧹 Clear Stale Nodes | One-click purge of cached (stale) nodes from the store via Settings |
| 🗑️ Delete Single Node | Remove any individual node from the persistent store via the red "Delete" button on its card, with confirmation prompt |
| 🌓 Dark/Light Theme | Persistent theme toggle in the header |
| 📥 Map Export (KML/GPX) | Export visible GPS-fixed nodes as KML or GPX from the Map view |
| 📍 Waypoints | Capture and display mesh-broadcast WAYPOINT_APP packets as amber teardrop markers on the map, plus locally create/delete waypoints with name, description, and emoji icon via a floating panel (GPS optional — defaults to map centre). Markers are draggable to reposition. Persisted in waypoints.json and surviving restarts |
| 📏 Ruler | Click-to-measure point-to-point distances on the map with dashed polylines and live distance labels. Samples elevation from node position history and displays total distance, elevation gain/loss, and a canvas-drawn elevation profile chart. Map toolbar auto-minimises when active |
| 📡 Neighbor Info | Per-node SNRs from NEIGHBORINFO_APP packets displayed on node cards |
| 🗺️ Position History Trails | GPS fix history (up to 200 fixes/node) persisted server-side, rendered as polylines on the map with toggle |
| 📊 Airtime Trends | Channel utilization & airtime utilization charts with a 30-minute rolling window |
| 🔍 Message Search | Free-text search across message history per conversation |
| 🎛️ Collapsible Map Controls | Collapse/expand overlay toggle buttons on the map |
| 🐳 Standalone Docker | Run NodePulse completely independently of Home Assistant using the Dockerfile.standalone container |
| 🎚️ Node Signal Filter | Filter the nodes grid by signal strength (Excellent, Good, Fair, Poor) using a stable rolling snr_avg calculation |
| ☁️ MQTT Bridge | Built-in bidirectional MQTT bridge. Ingests traffic from external brokers with a robust geospatial/portnum/node-ID filter pipeline. Optionally forwards packets to the local radio. Includes Web UI configuration. |
| 🤖 Telegram Bot | Bidirectional Telegram Bot bridge. Inbound mesh text messages are automatically forwarded to an authorized Telegram chat. Send broadcasts or DMs back to the mesh from Telegram using bot commands. Includes /status, /nodes, /send, and /dm commands. Zero extra dependencies — uses the built-in aiohttp library. |
| 🎛️ Comprehensive Settings Page | The Web UI Settings tab reflects every addon configuration option in real time — connection & mesh status, HA integration keys and token validation, the full MQTT bridge config (broker port, credential status, topic, geo filter, portnum allowlist, node blocklist), the Telegram bot (status, token, authorized chats, relay channels/DMs, commands), the auto responder, scan interval, and log level. Secrets are always masked |
block-beta
columns 3
Mesh["🌐 Meshtastic\nMesh Network"] space:1 HA["🏠 Home Assistant OS"]
space:3
Node["📡 Meshtastic\nNode (TCP :4403)"] space:1 block:addon:1
addonLabel["NodePulse Addon\n(Docker Container)"]
backend["app/main.py\naiohttp :8099"]
conn["connection.py\nTCP client + reconnect"]
mqtt["mqtt_bridge.py\nMQTT bridge + filter"]
telegram["telegram_bot.py\nTelegram bridge"]
store["nodes.json, messages.json,\ntraceroutes.json, tags.json,\nposition_history.json, channels.json,\nwaypoints.json persistent stores"]
routes["routes.py\nREST API"]
ui["web_ui/\nDashboard, Nodes, Map,\nTopology, Messages, Packets, Settings"]
end
space:3
space:1 space:1 block:integration:1
intLabel["Custom Integration\ncustom_components/nodepulse"]
coord["coordinator.py\nDataUpdateCoordinator"]
bs["binary_sensor.py"]
sens["sensor.py"]
dt["device_tracker.py"]
notify["notify.py\nMesh notify platform"]
end
Node -->|"TCP stream"| conn
conn --> store
store --> routes
conn --> routes
conn -->|"packet callbacks"| mqtt
conn -->|"packet callbacks"| telegram
routes --> ui
routes -->|"REST relay"| coord
sequenceDiagram
autonumber
participant HA as Home Assistant Core
participant C as DataUpdateCoordinator
participant API as NodePulse Addon API
participant M as Meshtastic Node
HA->>C: async_config_entry_first_refresh()
activate C
C->>API: GET /api/status
C->>API: GET /api/nodes
Note over C,API: Both requests run in parallel (asyncio.gather)
API->>M: reads cached node DB
M-->>API: node list + metrics
API-->>C: JSON response
C-->>HA: coordinator.data updated
deactivate C
loop Every scan_interval seconds
HA->>C: scheduled refresh
C->>API: GET /api/status + GET /api/nodes
API-->>C: fresh snapshot
C-->>HA: push state to all entities
HA->>HA: async_write_ha_state() on each entity
end
erDiagram
CONFIG_ENTRY ||--o{ NODE_DEVICE : "creates one per tracked node"
CONFIG_ENTRY ||--|| NODEPULSE_DEVICE : "owns"
NODEPULSE_DEVICE {
string identifier "entry_id"
string name "NodePulse"
}
NODEPULSE_DEVICE ||--|| CONNECTION_BINARY_SENSOR : has
NODEPULSE_DEVICE ||--|| NODE_COUNT_SENSOR : has
CONNECTION_BINARY_SENSOR {
string device_class "connectivity"
bool is_on "addon connected?"
}
NODE_COUNT_SENSOR {
string state_class "measurement"
int value "visible node count"
}
NODE_DEVICE {
string identifier "node hex ID"
string name "Mesh Node !abcd1234"
}
NODE_DEVICE ||--|| SNR_SENSOR : has
NODE_DEVICE ||--|| HOPS_SENSOR : has
NODE_DEVICE ||--|| LAST_HEARD_SENSOR : has
NODE_DEVICE ||--|| BATTERY_SENSOR : has
NODE_DEVICE ||--|| VOLTAGE_SENSOR : has
NODE_DEVICE ||--|| CHANNEL_UTIL_SENSOR : has
NODE_DEVICE ||--|| AIR_UTIL_SENSOR : has
NODE_DEVICE ||--|| UPTIME_SENSOR : has
NODE_DEVICE ||--|| ROLE_SENSOR : has
NODE_DEVICE ||--o| GPS_TRACKER : "has (if GPS fix)"
NODE_DEVICE ||--|| ONLINE_BINARY_SENSOR : has
NODE_DEVICE ||--o| LAST_MESSAGE_RECEIVED_SENSOR : has
NODE_DEVICE ||--o| LAST_MESSAGE_SENT_SENSOR : has
SNR_SENSOR { string unit "dB" }
HOPS_SENSOR { string unit "hops" }
LAST_HEARD_SENSOR { string device_class "timestamp" }
BATTERY_SENSOR { string unit "%" }
VOLTAGE_SENSOR { string unit "V" }
CHANNEL_UTIL_SENSOR { string unit "%" }
AIR_UTIL_SENSOR { string unit "%" }
UPTIME_SENSOR { string unit "s" }
ROLE_SENSOR { string unit "" }
ONLINE_BINARY_SENSOR { string device_class "connectivity" }
GPS_TRACKER { string source_type "gps" }
Because NodePulse consists of both an addon and an integration, both pieces must be installed.
- In Home Assistant, go to Settings → Add-ons → Add-on Store.
- Click the three vertical dots (⋮) in the top right and select Repositories.
- Add this repository URL:
https://github.com/garethmo/NodePulse - Close the modal and wait for the store to refresh.
- Scroll down to NodePulse Addon Repository and click NodePulse.
- Click Install.
- Configure the addon options and start it.
(For developers: copy the nodepulse-addon folder to your /addons directory for local installation.)
- Open HACS in Home Assistant.
- Click the three dots (⋮) in the top right and select Custom repositories.
- Add
https://github.com/garethmo/NodePulseas an Integration. - Click Download on the NodePulse integration.
- Restart Home Assistant.
- Go to Settings → Integrations → Add Integration and search for NodePulse.
- Enter the addon URL. The default value (
http://a0d7b954-nodepulse:8099) represents the addon's supervisor DNS name.- If you installed NodePulse as a local addon, the DNS name is
http://local_nodepulse_addon:8099. - The integration features auto-discovery and will try both standard and local DNS names automatically. You can leave the default or leave it blank.
- Do not use
http://localhost:8099— from the integration's perspective,localhostis Home Assistant itself, not the addon container.
- If you installed NodePulse as a local addon, the DNS name is
Run just the Web UI dashboard — all core features work without HA (dashboard, map, topology, messaging, packet inspector, etc.). See STANDALONE_DOCKER.md for the full guide.
git clone https://github.com/garethmo/NodePulse.git
cd NodePulse/nodepulse-addon
docker build -t nodepulse:latest -f Dockerfile.standalone .
docker run -d --name nodepulse -p 8099:8099 -v /path/to/config.json:/app/dev_options.json:ro nodepulse:latestOpen http://localhost:8099 in your browser.
NodePulse reaches your Meshtastic node over TCP. Meshtastic firmware allows only ONE TCP client per node, so you must choose how NodePulse connects.
| Mode | connection_type |
Connects to | Use when |
|---|---|---|---|
| Direct (default) | direct |
the Meshtastic node itself | NodePulse is the only TCP client on the node |
| Proxy | proxy |
the official Meshtastic HA integration's TCP proxy | Running both the official integration and NodePulse |
⚠️ The Meshtastic node firmware permits a single TCP connection. The official integration and NodePulse cannot both connect directly to the same node. Either usedirectmode with the official integration disabled, or useproxymode.
The official Meshtastic integration can expose a TCP Proxy that owns the node's single connection:
- In the official integration, enable the TCP Proxy option (default port
4403). - Set NodePulse options:
connection_type:proxyproxy_host:homeassistant(Docker DNS name of HA Core — not the node's LAN IP)proxy_port:4403(must match the integration's proxy port)
| Option | Type | Default | Description |
|---|---|---|---|
connection_type |
direct | proxy |
direct |
How NodePulse reaches the node |
meshtastic_host |
string | — | Direct mode: IP/hostname of your Meshtastic node |
meshtastic_port |
int | 4403 |
Direct mode: TCP port of the node's Meshtastic interface |
proxy_host |
string | (empty) | Proxy mode: host running the official integration (homeassistant) |
proxy_port |
int | 4403 |
Proxy mode: TCP proxy port |
access_key |
string | (empty) | Optional access key if your node requires authentication |
scan_interval |
int | 30 |
How often (seconds) the integration polls the addon (10–300) |
ignored_nodes |
list | [] |
List of node hex IDs to exclude from all API responses |
ha_base_url |
string | (auto) | Override the Home Assistant base URL for track-in-HA relay |
disable_token_validation |
bool | false |
Skip supervisor token validation (needed for some custom Docker setups) |
NodePulse includes an advanced, bidirectional MQTT Bridge capable of ingesting mesh traffic from an external broker (e.g., mqtt.meshtastic.org) and acting as a selective firewall to prevent distant public nodes from polluting your local mesh database.
| Option | Type | Default | Description |
|---|---|---|---|
mqtt_enabled |
bool | false |
Enable the MQTT Bridge |
mqtt_address |
string | mqtt.meshtastic.org |
Hostname/IP of the MQTT broker |
mqtt_port |
int | 1883 |
TCP port of the broker |
mqtt_username |
string | meshdev |
Username for authentication (leave empty for anonymous) |
mqtt_password |
string | large4cats |
Password for authentication |
mqtt_topic |
string | msh/+ |
The topic to subscribe to |
mqtt_forwarding_enabled |
bool | false |
Because public brokers like mqtt.meshtastic.org carry thousands of global nodes, blindly ingesting all traffic will quickly overwhelm both NodePulse and your local radio if forwarding is enabled. NodePulse provides a 3-stage filter pipeline (processed in order of cost):
- Node Blocklist (
mqtt_node_blocklist): A list of explicit node IDs (e.g.,!abcd1234) to permanently ignore. - PortNum Allowlist (
mqtt_portnum_allowlist): Only packets matching these app types are permitted. For example, setting this to["TEXT_MESSAGE_APP", "POSITION_APP", "NODEINFO_APP"]drops all routing, telemetry, and neighbor-info packets. If left empty, all types are allowed. - Geospatial Bounding Box (
mqtt_geo_filter_enabled):- Define a geographic fence using
mqtt_lat_min,mqtt_lat_max,mqtt_lng_min, andmqtt_lng_max. - When a
POSITION_APPpacket arrives, it is dropped if it falls outside the box. - Crucially, NodePulse caches this "out-of-bounds" status per node. If a node is out-of-bounds, NodePulse drops all subsequent packets (messages, telemetry, etc.) from that node until it moves back inside the box. This prevents distant nodes from bypassing the geo-fence by sending non-position packets.
- Define a geographic fence using
(Note: The bounding box must be valid — lat_min < lat_max and lng_min < lng_max — or the geo-filter will automatically disable itself with a warning).
NodePulse includes a built-in Telegram Bot that bridges your Meshtastic mesh to a private Telegram chat. When enabled, inbound mesh messages are automatically forwarded to your Telegram chat and you can send messages back to the mesh using bot commands.
ℹ️ No extra dependencies required. The bot uses
aiohttp, which is already part of NodePulse's existing stack.
- Open Telegram and search for @BotFather.
- Send
/newbotand follow the prompts to choose a name and username. - BotFather will reply with your Bot Token — it looks like:
7123456789:AAFxxxxxxxx_xxxxxxxxxxxxxxxxxx. - Copy this token — you'll need it in the addon config.
Your Chat ID is the unique numeric identifier of your Telegram account or group that the bot is authorized to talk to.
For a private chat (recommended):
- Start a conversation with your new bot by searching for its username and clicking Start.
- Open a browser and visit:
https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates - Send any message to your bot from Telegram, then refresh the page.
- Look for
"chat":{"id":XXXXXXXXX}— that number is your Chat ID.
For a group chat:
- Add the bot to the group.
- Send a message in the group, then use the
getUpdatesURL above. - The group Chat ID will be a negative number (e.g.
-1001234567890).
Set these options in the NodePulse addon configuration (HA UI → NodePulse → Configuration):
| Option | Type | Default | Description |
|---|---|---|---|
telegram_enabled |
bool | false |
Enable the Telegram Bot integration |
telegram_bot_token |
string | (empty) | Your BotFather-issued bot token |
telegram_chat_id |
string | (empty) | Numeric ID of the authorized chat or group |
telegram_forward_channels |
string | 0 |
Mesh channel indices whose messages are relayed to Telegram. Comma or space separated, e.g. 0, 1, 2 |
telegram_forward_dms |
bool | true |
Whether inbound mesh DMs are also relayed to Telegram |
telegram_allow_commands |
bool | true |
Allow sending commands from Telegram back to the mesh |
Minimal working config:
{
"telegram_enabled": true,
"telegram_bot_token": "7123456789:AAFxxxxxxxx_xxxxxxxxxxxxxxxxxx",
"telegram_chat_id": "123456789"
}After saving, restart the addon. The Settings tab in the NodePulse Web UI will show Telegram Bot: ✓ Enabled.
Once the bot is running, send these commands from your authorized Telegram chat:
| Command | Description |
|---|---|
/help |
List all available commands |
/status |
Show radio connection state, visible node count, and battery level |
/nodes |
List the top 20 nodes by last-heard time, with their SNR |
/channels |
List the radio's configured channels with their indices |
/send <message> |
Broadcast a text message to the primary mesh channel (Ch 0) |
/send <ch> <message> |
Broadcast to a specific channel, e.g. /send 1 Hello! (or /send #1 Hello!) |
/dm !nodeid <message> |
Send a direct message to a specific node (e.g. /dm !a1b2c3d4 Hello!) |
- The bot only processes messages from the configured
telegram_chat_id. Any message from any other chat is silently discarded. This means even if someone finds your bot's username, they cannot send commands to your radio. - The
telegram_bot_tokenis stored as an addon option. Keep it private and regenerate it with BotFather if it is ever leaked. - Outgoing messages from the local node are not echoed back to Telegram to prevent relay loops.
The Settings tab in the NodePulse dashboard reflects every addon configuration option in real time. It is read-only — values are edited in the Home Assistant add-on Configuration tab — but shows the complete live state so you can verify what the addon actually loaded. Settings are grouped into:
| Group | Contents |
|---|---|
| Connection | Link status, connection mode (Direct/Proxy), Meshtastic host & port, and proxy host & port (only shown in proxy mode) |
| Mesh | Visible node count, ignored node IDs, and a one-click Clear stale nodes action |
| Home Assistant Integration | HA base URL, access key (masked), and token-validation toggle |
| MQTT Bridge | Forwarding mode, broker address & port, username/password presence (masked), topic, geo-filter bounds, portnum allowlist, and node blocklist |
| Telegram Bot | Status, bot token (masked), authorized chat ID(s), relay channels, DM relay, and command permission |
| Auto Responder | Status and configured welcome message |
| Schedule & Logging | Scan interval and log level |
| About | NodePulse version |
Secrets are always masked (●●●●●● (set) / Not set), and rows for disabled features render as — rather than leaking empty config values.
The addon relays the track request to Home Assistant core, which only answers if the NodePulse custom integration is loaded. A 404 means HA has no /api/nodepulse/track route yet.
- Confirm
custom_components/nodepulse/is inside your HAconfig/custom_components/directory. - Restart HA, then add the NodePulse integration via Settings → Integrations → Add Integration.
- Verify in the HA logs that relay views registered.
- Once the integration is loaded, the 502s resolve automatically.
- The integration uses auto-discovery. Leave the host field as default or blank.
- Do not use
http://localhost:8099— from the integration,localhostis HA itself. - Confirm the addon shows
connected: truein its log before adding the integration.
- The addon performs an active health probe every 60s. A dropped TCP session is detected and reconnected automatically.
- Fresh connections get a 30s grace period before an empty node DB is treated as a dead connection.
cd nodepulse-addon/
# Edit dev_options.json with your node's IP address
pip install -r requirements.txt
python -m app.main
# Open http://localhost:8099/ui/index.html| Component | Technology |
|---|---|
| Addon backend | Python 3.12 + aiohttp |
| Meshtastic client | meshtastic PyPI library |
| Web UI charts | Chart.js (CDN) |
| Web UI mapping | Leaflet.js (CDN) |
| HA Integration | Python 3.12 + HA Core APIs |
- All code comments, commit messages, and documentation must be in English.
- Run the linter before submitting a PR.
MIT © NodePulse Contributors





