Plugin API
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:
| Status | Meaning |
|---|---|
running | Plugin loaded, all API routes accessible |
stopped | Manually stopped. .disabled marker file exists. Routes return 503. |
crashed | Exception during module import or init_plugin(). Error detail in error field. |
hung | Watchdog-enabled plugin that has not heartbeated within 120 s. Routes return 503. |
loading | Plugin is currently being imported (transient state during startup). |
invalid_manifest | Manifest rejected — missing required watchdog field or invalid id. |
pending_restart | Start 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)
- Call
POST /api/system/plugins/{id}/toggle?action=stop - Server creates
plugins/{id}/.disabledmarker file on disk - In-memory status set to
stopped - Plugin removed from watchdog dict (no more hang checking)
- SSE
plugin_updateevent broadcast:{"id": "...", "status": "stopped"} - 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)
- Call
POST /api/system/plugins/{id}/toggle?action=start - Server deletes
plugins/{id}/.disabledmarker file - If the plugin was previously
running(module is loaded in memory): status →running, watchdog re-registered ifmanifest.watchdog=true - SSE
plugin_updateevent broadcast:{"id": "...", "status": "running"} - 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:
- Server deletes the
.disabledmarker file - Status set to
pending_restart - SSE
plugin_updatebroadcast - 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
idmust match[a-zA-Z0-9_-]+ - Manifest must explicitly declare
"watchdog": trueor"watchdog": false - A plugin with the same
idmust not already be installed (returns409 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[a-zA-Z0-9_-]+ and match the directory name.watchdogtrue = background task must heartbeat every <120 s. false = passive plugin, never flagged as hung.entry_pointmain.py. Import has a 10-second hard timeout.router_prefixplugin_router. Default: /api/plugins/{id}.static_prefixstatic/ 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.