MeshDash Docs
R2.0
/
Home Development Plugin Development

Plugin Development

Development plugin development manifest init_plugin context router fastapi watchdog hooks build extend
Build plugins to extend MeshDash — manifest spec, init_plugin context, FastAPI routes, static files, and the watchdog API.

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.

The plugin directory is at <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
Required. Unique identifier. Only [a-zA-Z0-9_-] characters. Must match the directory name.
watchdog
Required. Cannot be omitted — plugin will be rejected. Set true if the plugin runs a background task that must heartbeat. Set false for passive plugins.
entry_point
Python file to import. Default: main.py. The module is imported with a 10-second hard timeout.
router_prefix
URL prefix for all routes defined in plugin_router. Default: /api/plugins/{id}.
static_prefix
URL prefix for files in the static/ subdirectory. Default: /static/plugins/{id}.
nav_menu
Optional. Array of sidebar navigation items added to the MeshDash UI.

Python Entry Point

Your main.py (or the file named in entry_point) must export:

  • plugin_router — a FastAPI APIRouter instance (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"]
Dict of all active NodeSlot objects (multi-radio slots).
context["event_loop"]
The main asyncio event loop. Use with asyncio.run_coroutine_threadsafe() to schedule async work from threads.
context["logger"]
A Python logger pre-configured for the plugin (plugin.{id}). Log lines appear in the Plugin Logs UI.
context["plugin_watchdog"]
Shared dict for watchdog heartbeats. Write context["plugin_watchdog"][pid] = time.time() to reset the timer.
context["plugin_id"]
The plugin's own 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/hello
  • POST /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.