Plugin Development
MeshDash plugins extend the application with new API endpoints, background tasks, and UI pages — all without modifying core files. Plugins are loaded at startup from the /plugins/ directory.
<install_dir>/plugins/. MeshDash creates it automatically if it doesn't exist.Plugin Directory Structure
plugins/
└── my_plugin/
├── manifest.json ← required
├── main.py ← Python entry point (optional)
├── static/ ← static files (optional)
│ └── index.html
└── requirements.txt ← (future: auto-install)
manifest.json Reference
{
"id": "my_plugin",
"name": "My Plugin",
"version": "1.0.0",
"description": "Does something useful.",
"watchdog": false,
"entry_point": "main.py",
"router_prefix": "/api/plugins/my_plugin",
"static_prefix": "/static/plugins/my_plugin",
"nav_menu": [
{
"label": "My Plugin",
"href": "/plugin/my_plugin/index.html",
"icon": "fa-star"
}
]
}
id[a-zA-Z0-9_-] characters. Must match the directory name.watchdogtrue if the plugin runs a background task that must heartbeat. Set false for passive plugins.entry_pointmain.py. The module is imported with a 10-second hard timeout.router_prefixplugin_router. Default: /api/plugins/{id}.static_prefixstatic/ subdirectory. Default: /static/plugins/{id}.nav_menuPython Entry Point
Your main.py (or the file named in entry_point) must export:
plugin_router— a FastAPIAPIRouterinstance (optional, but needed for API endpoints)init_plugin(context)— called after the plugin is loaded with application context injected (optional)
init_plugin Context
The context dict passed to init_plugin contains:
context["db_manager"]DatabaseManager instance — access SQLite via its methods.context["meshtastic_data"]MeshtasticData instance — read nodes, packets, stats, local node info, etc.context["connection_manager"]MeshtasticConnectionManager — call sendText() to send messages to the mesh.context["node_registry"]NodeSlot objects (multi-radio slots).context["event_loop"]asyncio.run_coroutine_threadsafe() to schedule async work from threads.context["logger"]plugin.{id}). Log lines appear in the Plugin Logs UI.context["plugin_watchdog"]context["plugin_watchdog"][pid] = time.time() to reset the timer.context["plugin_id"]id string.Minimal Example Plugin
A simple plugin that exposes a custom API endpoint:
# plugins/hello_mesh/main.py
import time
import asyncio
from fastapi import APIRouter
plugin_router = APIRouter()
_connection_manager = None
_mesh_data = None
_logger = None
@plugin_router.get("/hello")
async def say_hello():
"""Returns hello with current node info."""
node_id = _mesh_data.local_node_id if _mesh_data else "unknown"
return {"message": "Hello from plugin!", "local_node": node_id}
@plugin_router.post("/announce")
async def announce(text: str):
"""Sends an announcement to the mesh."""
if _connection_manager:
await _connection_manager.sendText(text, destinationId="^all", channelIndex=0)
return {"status": "sent", "text": text}
return {"status": "error", "message": "Radio not connected"}
def init_plugin(context: dict):
global _connection_manager, _mesh_data, _logger
_connection_manager = context["connection_manager"]
_mesh_data = context["meshtastic_data"]
_logger = context["logger"]
_logger.info("Hello Mesh plugin loaded!")
With manifest:
{
"id": "hello_mesh",
"name": "Hello Mesh",
"version": "1.0.0",
"watchdog": false,
"entry_point": "main.py",
"nav_menu": []
}
This plugin's endpoints become available at:
GET /api/plugins/hello_mesh/helloPOST /api/plugins/hello_mesh/announce?text=...
Background Task Plugin (with Watchdog)
For plugins that run a persistent background task:
# plugins/my_poller/main.py
import time
import asyncio
import threading
from fastapi import APIRouter
plugin_router = APIRouter()
_running = False
_context = None
@plugin_router.get("/status")
async def status():
return {"running": _running}
def _background_loop(ctx):
global _running
pid = ctx["plugin_id"]
logger = ctx["logger"]
watchdog = ctx["plugin_watchdog"]
conn = ctx["connection_manager"]
event_loop = ctx["event_loop"]
_running = True
logger.info("Background poller started")
while _running:
try:
# Do your work here
logger.debug("Poll cycle")
# Heartbeat — MUST do this if watchdog=true in manifest
watchdog[pid] = time.time()
except Exception as e:
logger.error(f"Poll error: {e}")
time.sleep(30)
def init_plugin(context: dict):
global _context
_context = context
t = threading.Thread(target=_background_loop, args=(context,), daemon=True)
t.start()
context["logger"].info("Poller plugin init complete")
Manifest for this plugin sets "watchdog": true:
{
"id": "my_poller",
"name": "My Poller",
"version": "1.0.0",
"watchdog": true,
"entry_point": "main.py"
}
Route Security
All plugin routes automatically get a state-check dependency injected by the PluginManager. If the plugin's status is not "running", or if the watchdog has fired, the dependency raises 503 Service Unavailable. Your plugin routes don't need to implement this check themselves.
Disabling a Plugin
Create an empty .disabled file in the plugin directory to prevent it from loading:
touch /opt/meshdash/plugins/my_plugin/.disabled
This is also what the POST /api/system/plugins/{id}/toggle?action=stop endpoint does internally.