MeshDash Docs
R2.0
/
Home API Reference Plugin API

Plugin API

API Reference plugins plugin install manifest watchdog api toggle start stop remove logs available lifecycle marketplace pending_restart
Install, manage, and monitor plugins. Full lifecycle, manifest spec, watchdog system, and all management endpoints.

The Plugin API manages MeshDash's extension system. Plugins are directories under /plugins/ containing a manifest.json and optionally a Python entry point. All plugin management endpoints require authentication. For a complete guide to the frontend Plugin UI, see System Views — Plugins. For building plugins, see Plugin Development.

List Installed Plugins

GET /api/system/plugins — Auth Required

GET /api/system/plugins
→ {
    "status": "success",
    "plugins": {
      "my_plugin": {
        "manifest": {"id": "my_plugin", "name": "My Plugin", "version": "1.0.0", "watchdog": false},
        "status": "running",
        "error": null,
        "path": "/opt/meshdash/plugins/my_plugin",
        "watchdog_monitored": false,
        "last_watchdog_ping": null
      }
    }
  }

Possible status values and their meaning:

StatusMeaning
runningPlugin loaded, all API routes accessible
stoppedManually stopped. .disabled marker file exists. Routes return 503.
crashedException during module import or init_plugin(). Error detail in error field.
hungWatchdog-enabled plugin that has not heartbeated within 120 s. Routes return 503.
loadingPlugin is currently being imported (transient state during startup).
invalid_manifestManifest rejected — missing required watchdog field or invalid id.
pending_restartStart was requested on a crashed/stopped/hung plugin. Module reload requires full service restart.

Full Plugin Lifecycle

Understanding what happens on each state transition is critical for managing plugins reliably.

Running → Stopped (STOP action)

  1. Call POST /api/system/plugins/{id}/toggle?action=stop
  2. Server creates plugins/{id}/.disabled marker file on disk
  3. In-memory status set to stopped
  4. Plugin removed from watchdog dict (no more hang checking)
  5. SSE plugin_update event broadcast: {"id": "...", "status": "stopped"}
  6. All plugin API routes now return 503 Plugin stopped

Effect: Immediate. No restart needed. The Python module stays in memory — it is just blocked at the route dependency level.

Stopped → Running (START action, if module is still in memory)

  1. Call POST /api/system/plugins/{id}/toggle?action=start
  2. Server deletes plugins/{id}/.disabled marker file
  3. If the plugin was previously running (module is loaded in memory): status → running, watchdog re-registered if manifest.watchdog=true
  4. SSE plugin_update event broadcast: {"id": "...", "status": "running"}
  5. Plugin routes immediately re-enabled

Response:

→ {"status": "success", "message": "Plugin my_plugin running."}

Crashed/Stopped/Hung → pending_restart (START action, module not in memory)

If the plugin was crashed, stopped from a previous restart, or hung, the Python module may not be importable without a fresh process. In this case:

  1. Server deletes the .disabled marker file
  2. Status set to pending_restart
  3. SSE plugin_update broadcast
  4. Response includes "requires_restart": true
→ {
    "status": "success",
    "message": "Plugin my_plugin enabled. A system restart is required to load it.",
    "requires_restart": true
  }

The frontend shows an APPLY & RESTART button when any action returns requires_restart: true. After restarting (POST /api/system/restart), the plugin directory has no .disabled file, so PluginManager.load_all() loads it fresh on boot.

Toggle Plugin (Start / Stop)

POST /api/system/plugins/{plugin_id}/toggle?action={start|stop} — Auth Required

# Stop a running plugin
POST /api/system/plugins/my_plugin/toggle?action=stop
→ {"status": "success", "message": "Plugin my_plugin stopped."}

# Start a stopped plugin (immediate if module is in memory)
POST /api/system/plugins/my_plugin/toggle?action=start
→ {"status": "success", "message": "Plugin my_plugin running."}

# Start a crashed plugin (requires restart)
POST /api/system/plugins/my_plugin/toggle?action=start
→ {"status": "success", "message": "...", "requires_restart": true}

Invalid action values return 400.

Plugin Logs

GET /api/system/plugins/{plugin_id}/logs — Auth Required

Returns the last 250 log lines from the plugin's plugin.{id} Python logger captured in a circular in-memory buffer. Lines are captured regardless of root logger level.

GET /api/system/plugins/my_plugin/logs
→ {
    "plugin_id": "my_plugin",
    "count": 47,
    "max": 250,
    "logs": [
      {"t": 1705329000.0, "lvl": "INFO",  "msg": "14:30:00 INFO Plugin started"},
      {"t": 1705329060.0, "lvl": "DEBUG", "msg": "14:31:00 DEBUG Processing event"},
      {"t": 1705329070.0, "lvl": "ERROR", "msg": "14:31:10 ERROR Connection refused"}
    ]
  }

DELETE /api/system/plugins/{plugin_id}/logs — Auth Required

Clears the in-memory log buffer. Does not affect Python logging output to files or journald.

DELETE /api/system/plugins/my_plugin/logs
→ {"status": "success", "message": "Log buffer cleared for plugin 'my_plugin'."}

Remove Plugin

DELETE /api/system/plugins/{plugin_id} — Auth Required

Permanently deletes the plugin directory from disk using shutil.rmtree(). Returns requires_restart: true because the Python module stays in memory until the process restarts.

DELETE /api/system/plugins/my_plugin
→ {
    "status": "success",
    "message": "Plugin my_plugin removed. Please restart the system.",
    "requires_restart": true
  }

Install Plugin (ZIP Upload)

POST /api/system/plugins/install — Auth Required

Accepts a multipart file upload of a .zip archive.

curl -b "access_token=Bearer ..." \
     -F "file=@my_plugin.zip" \
     http://device-ip:8000/api/system/plugins/install

Validation (all must pass or the upload is rejected):

  • File extension must be .zip
  • Archive must contain a non-empty manifest.json
  • Manifest must have valid JSON syntax
  • Manifest id must match [a-zA-Z0-9_-]+
  • Manifest must explicitly declare "watchdog": true or "watchdog": false
  • A plugin with the same id must not already be installed (returns 409 Conflict)
→ {
    "status": "success",
    "message": "Plugin 'My Plugin' installed successfully. Restart required.",
    "requires_restart": true
  }

Install Plugin (Remote URL)

POST /api/system/plugins/install-remote — Auth Required

Downloads a .zip from a public URL server-side. URL is SSRF-validated before the download begins (rejects private/RFC1918 IP ranges).

POST /api/system/plugins/install-remote
Content-Type: application/json

{"url": "https://example.com/my_plugin.zip"}

Same validation rules as the ZIP upload apply. Returns the same response shape.

Plugin Navigation Menu

GET /api/system/plugins/menu — Auth Required

Aggregates nav_menu entries from all running plugins' manifests. Called by app.js on startup to inject plugin entries into the sidebar nav.

→ {"nav_items": [{"label": "My Plugin", "href": "/plugin/my_plugin/index.html", "icon": "fa-star"}]}

Available Plugins (Marketplace)

GET /api/plugins/available — No Auth Required

Fetches the official plugin list from meshdash.co.uk/plugins.php. Returns whatever the server sends. Used by the Plugins view to populate the marketplace tab.

Plugin Frame Wrapper

GET /plugin/{plugin_id}/{file_path}

Serves a plugin's HTML file embedded inside the main MeshDash shell as an iframe. Returns 404 if the plugin is not in running state.

Manifest Specification

Every plugin directory must contain a valid manifest.json:

{
  "id": "my_plugin",
  "name": "My Plugin",
  "version": "1.0.0",
  "description": "Optional description.",
  "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. Must match [a-zA-Z0-9_-]+ and match the directory name.
watchdog
Required. Cannot be omitted — plugin is rejected on load if missing. true = background task must heartbeat every <120 s. false = passive plugin, never flagged as hung.
entry_point
Python file to import. Default: main.py. Import has a 10-second hard timeout.
router_prefix
URL prefix for all routes from plugin_router. Default: /api/plugins/{id}.
static_prefix
URL prefix for static files in static/ subdirectory. Default: /static/plugins/{id}.

Watchdog System

A background async worker (plugin_watchdog_worker()) runs every 30 seconds. For every plugin in _plugin_watchdog (only those with "watchdog": true in their manifest), it checks how long since the plugin last called:

context["plugin_watchdog"][plugin_id] = time.time()

If the gap exceeds 120 seconds, the plugin status is set to hung and its routes return 503. This never falsely triggers for "watchdog": false plugins regardless of how idle they are.