π‘ Hello Mesh β Plugin Developer Reference
The authoritative plugin developer reference for MeshDash. Every section is backed by the actual md.py source β no guesswork. Hit βΆ Run on any live endpoint to call it against your instance and see the real response.
FastAPI RouterContext Injection
DatabaseManagerLive Node Data
Radio ConnectionSSE Real-Time
Watchdog HeartbeatPlugin Lifecycle
βοΈ How the Plugin System Works
1
manifest.json
Declares ID, name, version, nav menu, router prefix, static prefix, and the required
Declares ID, name, version, nav menu, router prefix, static prefix, and the required
watchdog field2
main.py
Must expose
Must expose
plugin_router (a FastAPI APIRouter) and init_plugin(context)3
Context Injection
MeshDash calls
MeshDash calls
init_plugin() in a daemon thread, injecting db_manager, meshtastic_data, connection_manager, event_loop, logger, watchdog dict4
static/ folder
HTML/CSS/JS auto-served at
HTML/CSS/JS auto-served at
/static/plugins/{id}/ β framed inside the MeshDash shell via /plugin/{id}/5
Route Prefix
All Python routes mount at
All Python routes mount at
/api/plugins/{your_id}/your_route β a 503 guard is applied when stopped6
Watchdog
Opt-in monitor: write
Opt-in monitor: write
wd[plugin_id] = time.time() every β€120 s or all routes return 503 with status "hung"Getting Started
GUIDE
Required File Structure
Every plugin is a folder inside plugins/ with two required files
βΌ
About
Minimal Example
Every plugin must be a folder inside
plugins/ relative to md.py. The folder name can be anything but the id field in manifest.json is the canonical identifier MeshDash uses throughout.Directory Layout
plugins/
βββ my_plugin/ β folder name (anything)
βββ manifest.json β REQUIRED β tells core everything
βββ main.py β entry_point (configurable in manifest)
βββ static/ β optional β files auto-served over HTTP
βββ index.htmlWhat the core does at startup
Scans
plugins/ for subdirectories containing manifest.jsonValidates the manifest β rejects any plugin missing the watchdog key
Mounts the plugin's
static/ folder at static_prefixImports the
entry_point Python file and mounts plugin_router at router_prefixCalls
init_plugin(context) inside a daemon thread with 15 s timeoutmanifest.json β minimum viable
{
"id": "my_plugin",
"name": "My Plugin",
"version": "1.0.0",
"author": "You",
"description": "Does something cool.",
"entry_point": "main.py",
"router_prefix": "/api/plugins/my_plugin",
"static_prefix": "/static/plugins/my_plugin",
"watchdog": false
}main.py β minimum viable
from fastapi import APIRouter core_context: dict = {} plugin_router = APIRouter() def init_plugin(context: dict): core_context.update(context) @plugin_router.get("/hello") async def hello(): return {"message": "Hello from my_plugin!"}
That's it. No heartbeat needed when
"watchdog": false. Route is live at /api/plugins/my_plugin/hello.
MANIFEST
manifest.json β Every Field Explained
The manifest is validated on load β missing watchdog immediately rejects the plugin
βΌ
Fields
Full Example
β οΈ Breaking rule: The
watchdog field is REQUIRED. Any plugin whose manifest omits it is immediately set to invalid_manifest status and none of its routes or static files are mounted. Add it β even as false.| Field | Type | Description |
|---|---|---|
| id REQUIRED | string | Unique identifier. [a-zA-Z0-9_-]+ only. Used as the key in PLUGIN_REGISTRY and in all URLs. |
| name REQUIRED | string | Human-readable display name shown in Plugins page and nav menu. |
| version optional | string | Semantic version string e.g. "1.0.0". Shown in the Plugins page. |
| author optional | string | Author name shown in the Plugins page. |
| description optional | string | Short description shown in the Plugins page card. |
| entry_point REQUIRED | string | Filename of the Python file to import. Usually "main.py". Relative to the plugin folder. |
| router_prefix REQUIRED | string | URL prefix for all plugin_router routes. E.g. "/api/plugins/my_plugin". A 503 guard is applied when plugin is stopped. |
| static_prefix REQUIRED | string | URL prefix where the plugin's static/ folder is mounted. E.g. "/static/plugins/my_plugin". |
| watchdog REQUIRED | boolean | true = plugin must heartbeat every β€120 s or be marked hung. false = never monitored. Must be explicitly present. |
| nav_menu optional | array | Array of {"label":"β¦","icon":"fa-β¦","href":"β¦"} objects. Each becomes a nav sidebar entry. Links to /static/plugins/β¦ are auto-rewritten to /plugin/β¦. |
manifest.json β full example with nav menu
{
"id": "hello_mesh",
"name": "Hello Mesh",
"version": "1.0.0",
"author": "MeshDash",
"description": "Interactive API explorer and plugin tutorial.",
"entry_point": "main.py",
"router_prefix": "/api/plugins/hello_mesh",
"static_prefix": "/static/plugins/hello_mesh",
"watchdog": true,
"nav_menu": [{
"label": "Hello Mesh",
"icon": "fa-graduation-cap",
"href": "/plugin/hello_mesh/index.html"
}]
}
CONFIG
The MeshDash Config File β All Keys
.mesh-dash_config β KEY=value format, one per line
βΌ
All Keys
Example Config
MeshDash reads
.mesh-dash_config in the same directory as md.py. Simple KEY=value format β one per line, # for comments. Strings do not need quotes. Booleans accept true/false/1/0/yes/no/on.Plugin code can access the running config via
GET /api/system/config (auth required). Read it from your plugin using an httpx call to http://127.0.0.1:8000/api/system/config with the session cookie.| Key | Default | Description |
|---|---|---|
| MESHTASTIC_HOST | 192.168.0.0 | IP/hostname of the Meshtastic device (TCP mode). |
| MESHTASTIC_PORT | 4403 | TCP port of the Meshtastic device. |
| MESHTASTIC_CONNECTION_TYPE | SERIAL | SERIAL, TCP, or BLE. |
| MESHTASTIC_SERIAL_PORT | empty | Serial port path e.g. /dev/ttyUSB0. Leave empty to auto-detect. |
| MESHTASTIC_BLE_MAC | empty | BLE MAC address for BLE connection mode. |
| WEBSERVER_HOST | 0.0.0.0 | Host interface uvicorn binds to. |
| WEBSERVER_PORT | 8000 | HTTP port uvicorn listens on. |
| DB_PATH | meshtastic_data.db | Path to the main SQLite database. |
| TASK_DB_PATH | tasks.db | Path to the tasks/scheduler SQLite database. |
| MAX_PACKETS_MEMORY | 200 | Max packets kept in the in-process ring buffer. |
| HISTORY_DAYS | 1 | Days of average metrics history to keep. |
| LOG_LEVEL | INFO | Logging verbosity: DEBUG, INFO, WARNING, ERROR. |
| AUTH_SECRET_KEY | random | JWT signing secret. Change this in production. |
| AUTH_TOKEN_EXPIRE_MINUTES | 30 | How long session tokens are valid. |
| COMMUNITY_API | false | Enable the community C2 / heartbeat channel. |
| COMMUNITY_API_KEY | placeholder | API key for the community C2 service. |
| HEARTBEAT_INTERVAL_MINUTES | 1 | How often the core sends a C2 heartbeat if enabled. |
| SEND_LOCAL_NODE_LOCATION | true | Include local node GPS in C2 heartbeat. |
| SEND_OTHER_NODES_LOCATION | true | Include neighbour node GPS in C2 heartbeat. |
| LOCATION_OFFSET_ENABLED | false | Apply a random GPS offset to all reported positions. |
| LOCATION_OFFSET_METERS | 0.0 | Max offset radius in metres when offset is enabled. |
| C2_ACCESS_LEVEL | read | C2 permission tier: read, write, or admin. |
| C2_MAX_REQUESTS_PER_SYNC | 10 | Max commands processed per C2 sync cycle. |
| C2_SYNC_INTERVAL_SECONDS | 15 | How often the C2 worker polls for queued commands. |
| INITIAL_ADMIN_USERNAME | empty | Used only once during first-run setup. Auto-deleted after use. |
| INITIAL_ADMIN_PASSWORD | empty | As above. |
.mesh-dash_config β example
# MeshDash Configuration
MESHTASTIC_CONNECTION_TYPE=TCP
MESHTASTIC_HOST=192.168.1.50
MESHTASTIC_PORT=4403
WEBSERVER_PORT=8080
LOG_LEVEL=DEBUG
AUTH_SECRET_KEY=change_me_to_something_random_and_long
AUTH_TOKEN_EXPIRE_MINUTES=120
MAX_PACKETS_MEMORY=500
HISTORY_DAYS=7
LIFECYCLE
init_plugin & The Context Dict
What MeshDash injects at boot and the critical thread-safety rule
βΌ
Context Keys
Background Tasks
β οΈ Never call
asyncio.get_event_loop().create_task() from init_plugin. Inside a thread, this returns a different non-running loop. Your task will be silently created on a dead loop and never execute. Use asyncio.run_coroutine_threadsafe(coro, loop) with context["event_loop"] instead.| Key | Type | Description |
|---|---|---|
| db_manager | DatabaseManager | Direct access to the MeshDash SQLite layer. Provides get_all_nodes(), get_messages(), get_recent_packets(), etc. Use asyncio.to_thread() in async routes. |
| meshtastic_data | MeshtasticData | In-memory state object. .nodes, .local_node_info, .connection_status, .packets. Fast dict lookups β no to_thread needed. |
| connection_manager | MeshtasticConnectionManager | The radio interface. await cm.sendText(msg, destinationId, channelIndex). Always check cm.is_ready.is_set() before sending. |
| event_loop | asyncio.AbstractEventLoop | The live uvicorn event loop. Use with run_coroutine_threadsafe() to schedule async tasks from inside init_plugin. |
| logger | logging.Logger | Namespaced logger (plugin.<id>). Output captured in 250-line ring buffer accessible via GET /api/system/plugins/<id>/logs. |
| plugin_watchdog | dict[str, float] | Shared watchdog dict. Write context["plugin_watchdog"][context["plugin_id"]] = time.time() every β€120 s to stay alive. |
| plugin_id | str | Your plugin's registered ID string. Use as the key when writing to plugin_watchdog. |
Python β correct pattern for starting background tasks
import asyncio, time core_context: dict = {} plugin_router = APIRouter() async def _my_background_task(): while True: await asyncio.sleep(60) logger = core_context.get("logger") if logger: logger.info("Background tick") def init_plugin(context: dict): core_context.update(context) loop = core_context.get("event_loop") # the real uvicorn loop if loop is None: return # β Correct β schedules on the running loop from a thread asyncio.run_coroutine_threadsafe(_my_background_task(), loop) # β Wrong β get_event_loop() from a thread returns the wrong loop # asyncio.get_event_loop().create_task(_my_background_task())
WATCHDOG
Watchdog Heartbeat β How It Works
Opt-in monitoring: miss 120 s and all your routes return 503
βΌ
How It Works
Implementation
βΆ Live Status
MeshDash has a background task (
plugin_watchdog_worker) that wakes every 30 s and checks the last heartbeat timestamp for every opted-in plugin. If more than 120 seconds have passed since the last ping, the plugin is marked hung and all its routes return 503 Service Unavailable.This is opt-in. If your manifest has
"watchdog": false, the core never adds you to the watchdog dict and never marks you hung, no matter how long you are silent.Core internal logic
Module-level dict:
_plugin_watchdog: Dict[str, float] = {} β key = plugin id, value = last unix timestampOn load with
watchdog: true, core seeds it: _plugin_watchdog[pid] = time.time()Worker checks every 30 s: if
time.time() - _plugin_watchdog[pid] > 120 β status = "hung"Plugin pings by writing:
wd[pid] = time.time() where wd = context["plugin_watchdog"]Python β complete watchdog heartbeat implementation
import asyncio, time async def _watchdog_heartbeat(): while True: await asyncio.sleep(30) # ping every 30 s (well within 120 s limit) wd = core_context.get("plugin_watchdog") pid = core_context.get("plugin_id") if wd is not None and pid: wd[pid] = time.time() # reset the timer def init_plugin(context: dict): core_context.update(context) loop = core_context.get("event_loop") if loop: asyncio.run_coroutine_threadsafe(_watchdog_heartbeat(), loop)
β οΈ If you set
"watchdog": true but forget to implement the heartbeat, your plugin will start fine but go hung after 120 s. Either implement the heartbeat or use "watchdog": false.π Checking watchdogβ¦
Hit Check to query the watchdog status from /api/plugins/hello_mesh/info
LOGGING
Plugin Logging β Ring Buffer & API
Captured to a 250-line ring buffer, viewable via API or the Plugins page
βΌ
About
Code
βΆ Try It
The core attaches a
MemoryLogHandler to logging.getLogger("plugin.<id>") when your plugin loads. This captures up to 250 lines in a thread-safe ring buffer. You can view them in the Plugins page Logs modal, or call the API directly.API endpoints
GET /api/system/plugins/{plugin_id}/logs β returns last 250 log linesDELETE /api/system/plugins/{plugin_id}/logs β clears the ring bufferEach log entry:
{"t": unix_ts, "lvl": "INFO", "msg": "β¦"}Always use the injected logger β never hardcode a logger name
Python β correct logger usage
def init_plugin(context: dict): core_context.update(context) log = context.get("logger") # logging.getLogger("plugin.my_plugin") log.info("Plugin started") # In your route handlers: @plugin_router.get("/something") async def something(): log = core_context.get("logger") log.debug("Request received") log.warning("Something odd") log.error("Something broke") return {"ok": True}
Hit Run to emit test log lines then fetch the ring buffer
Core API Endpoints
These are the main MeshDash REST endpoints β the same ones the dashboard frontend uses. Call them from your plugin's JavaScript with fetch('/api/...') or from Python using httpx.
GET
/api/status
System health, connection state, local node info β also renews JWT if near expiry
βΌ
About
JS Example
βΆ Try It
The primary status endpoint. Call this on plugin load to check if it's safe to interact with the radio. Also handles sliding token renewal β calling /api/status refreshes your session if more than half the token lifetime has elapsed.
Response fields
is_system_ready β true when radio is fully connected and accepting commandsconnection_status β human-readable: "Connected", "Reconnecting", "Initializing", etc.local_node_info β your radio: node_id, long_name, hardware_model, firmware_version, channelslast_error β last connection error string if any (null when healthy)JavaScript β plugin frontend
async function checkStatus() { const res = await fetch('/api/status'); const data = await res.json(); if (!data.is_system_ready) { showWarning('Radio not ready: ' + data.connection_status); return; } const node = data.local_node_info; console.log('Local node:', node.long_name, node.node_id); } checkStatus();
Hit Run to fire this endpoint
GET
/api/stats
In-memory packet/node statistics β total counts, per-type breakdown, connection state
βΌ
About
βΆ Try It
Returns in-memory packet/node statistics as tracked by
MeshtasticData. Includes total counts, per-type breakdowns, and connection state. Resets on MeshDash restart β use DB counts for persistent totals.Key fields
packets_received_session, text_messages_session, position_updates_sessiontelemetry_reports_session, elapsed_time_session (seconds), nodes_seen_sessionAlso accessible as
mesh_data.get_serializable_stats() from plugin Python codeHit Run to fire this endpoint
GET
/api/system/connection_history?limit=60
Historical log of connection state changes β uptime graph data
βΌ
About
JS Example
βΆ Try It
Returns the radio connection status log. Entries are created on every state change and on a 60-second heartbeat. Values: 0.9=Connected, 0.5=Initialising/Waiting, 0.1=Disconnected. Useful for plotting uptime over time in a plugin.
Query Parameters
limitint Β· default: 60Max rows to return
JavaScript β uptime calculation
const history = await fetch( '/api/system/connection_history?limit=60' ).then(r => r.json()); // history is oldest-first, value: 0.9=up, 0.5=init, 0.1=down const uptime = history.filter(h => h.value >= 0.9).length; const pct = ((uptime / history.length) * 100).toFixed(1); console.log(`Uptime: ${pct}% over last ${history.length} samples`);
Hit Run to fire this endpoint
Nodes
GET
/api/nodes
Full in-memory nodes dict β same data powering the main dashboard map
βΌ
About
JS Example
βΆ Try It
Returns the complete live node dict keyed by node ID (e.g.
"!ab12cd34"). Each value contains user info, position, device metrics, last heard, SNR, RSSI, battery, voltage, and more. This is the same store powering the main dashboard map and node list.Key fields per node
node_id, long_name, short_name, last_heard, snr, rssibattery_level, voltage, latitude, longitude, altitudeisLocal: true on the node that is your directly connected radioUse
Object.values(nodes) to iterate in JavaScriptJavaScript β plugin frontend
const nodes = await fetch('/api/nodes').then(r => r.json()); const nodeList = Object.values(nodes); // Find nodes with GPS const located = nodeList.filter(n => n.latitude && n.longitude); // Find nodes seen in last 30 minutes const recent = nodeList.filter(n => n.last_heard > (Date.now()/1000) - 1800 ); // Find local node const localNode = nodeList.find(n => n.isLocal); console.log(`${nodeList.length} total, ${located.length} with GPS`);
Hit Run to fire this endpoint
GET
/api/nodes/{node_id}
Returns a single node by ID β 404 if not in memory
βΌ
About
βΆ Try It
Returns a single node object by ID. Returns HTTP 404 if the node ID is not currently in memory. For a memory-first + DB fallback pattern, see the plugin code example in the Hello Mesh demo endpoints.
Path Parameters
node_idstring Β· requiredNode ID in !hex format, e.g.
!a1b2c3d4Enter a node ID then hit Run
Packets
GET
/api/packets?limit=50
In-memory ring buffer β fast, no DB query, resets on restart
βΌ
About
βΆ Try It
Returns packets from the in-memory ring buffer (max size set by
MAX_PACKETS_MEMORY config key, default 200). Fast β no DB read. Use this for the live feed.Query Parameters
limitint Β· default: 50Max packets to return
Hit Run to fire this endpoint
GET
/api/packets/history?limit=100
Persistent packet history from SQLite β all packet types
βΌ
About
JS Example
βΆ Try It
Returns packets from the SQLite database. Survives restarts. Unlike messages/history which is text-only, this includes every meaningful packet type. Junk packets (Ack, Routing Error, Encrypted, Unknown) are intentionally not stored to keep the DB lean.
Packet types you'll see
Message β text message with decoded text payloadPosition β GPS coordinates with altitude, precision, satellite countTelemetry β battery, voltage, channel utilisation, environment sensorsNode Info β node identity broadcast (name, hardware model, role)Traceroute, Paxcounter, Range TestJavaScript β group by type
const packets = await fetch( '/api/packets/history?limit=50' ).then(r => r.json()); // Group by packet type const byType = packets.reduce((acc, p) => { const t = p.packet_type || 'Unknown'; acc[t] = (acc[t] || 0) + 1; return acc; }, {}); // Average SNR across all packets const withSnr = packets.filter(p => p.rx_snr != null); const avgSnr = withSnr.reduce((s,p) => s + p.rx_snr, 0) / withSnr.length; console.log(`Average SNR: ${avgSnr.toFixed(1)} dB`);
Hit Run to fire this endpoint
GET
/api/hardware_logs?limit=50
Hardware event log β reboots, disconnections, firmware events
βΌ
About
βΆ Try It
Hardware logs capture admin-level events on mesh nodes β reboots, firmware updates, config changes via Admin packets, disconnection events. MeshDash logs these as they arrive so you have an audit trail of hardware events across your mesh.
Response fields
node_id, event_type, details (JSON string), timestampUseful for building an audit log plugin or remote device monitoring tool
Hit Run to fire this endpoint
Message History
GET
/api/messages/history
Persistent message history with optional from_id, to_id, channel, limit filters
βΌ
About
JS Example
βΆ Try It
Returns text messages from the persistent database. Supports filtering by sender, recipient, channel, and time range. Returns newest-first. Use this endpoint for building a custom message display or chat plugin.
Query Parameters
from_idstring Β· optionalFilter by sender node ID e.g.
!ab12cd34to_idstring Β· optionalFilter by recipient. Use
^all for broadcasts.channelint Β· default: 0Channel index (0-7). Only used for broadcast messages.
limitint Β· default: 100Max rows to return
JavaScript β filtering examples
// Get last 20 messages on channel 0 const msgs = await fetch( '/api/messages/history?limit=20&channel=0' ).then(r => r.json()); // Get DMs between two nodes const dms = await fetch( '/api/messages/history?from_id=!a1b2c3d4&to_id=!e5f6a7b8&limit=50' ).then(r => r.json()); // Each message: from_id, to_id, channel, text, timestamp, rx_snr, rx_rssi msgs.forEach(m => { const dt = new Date(m.timestamp * 1000).toLocaleTimeString(); console.log(`[${dt}] ${m.from_id}: ${m.text}`); });
Hit Run to fire this endpoint
Send to Mesh
POST
/api/messages
Send a text message to the mesh β broadcast or DM β requires auth
AUTHβΌ
About
Python (Preferred)
This endpoint requires an authenticated session. From plugin Python code, use
connection_manager.sendText() directly instead β it's faster and doesn't require session handling.Request Body (JSON)
messagestring REQUIREDText string to transmit
channelint Β· default: 0Channel index 0-7
destinationstring Β· optionalTarget node ID for DM. Omit for broadcast to
^allResponse:
{"status":"sent","channel":0,"packet_id":β¦,"timestamp":β¦}Python β sending from plugin code (preferred)
async def send_to_mesh(): cm = core_context.get("connection_manager") if not cm or not cm.is_ready.is_set(): raise RuntimeError("Radio not ready") # Broadcast to all nodes on channel 0 await cm.sendText("Hello mesh!", destinationId="^all", channelIndex=0) # Direct message to a specific node await cm.sendText("Private msg", destinationId="!ab12cd34", channelIndex=0)
Also available:
POST /api/alert?msg=TEXT β convenience endpoint that broadcasts ALERT: {msg} as a system message to the mesh. Useful for testing.Network β Neighbors, Traceroutes, Waypoints
GET
/api/neighbors
Neighbor table β RF topology links with SNR values for each pair
βΌ
About
JS Example
βΆ Try It
Returns the neighbour table β all nodes that have reported their direct RF neighbours via the NeighborInfo protocol. Each entry contains node_id, neighbors (array with SNR), and timestamp. The foundation for mesh topology visualisations.
What this data enables
Building a force-directed graph or adjacency matrix of your mesh
Finding nodes with the most neighbours (potential relay bottlenecks)
Only populated if nodes have NeighborInfo module enabled in their config
JavaScript β adjacency list
const neighbors = await fetch('/api/neighbors').then(r => r.json()); const graph = {}; neighbors.forEach(n => { if (!graph[n.node_id]) graph[n.node_id] = []; graph[n.node_id].push({ id: n.neighbor_id, snr: n.snr }); }); // Find most connected nodes (potential relays) const sorted = Object.entries(graph) .sort((a, b) => b[1].length - a[1].length); console.log('Most connected:', sorted[0]);
Hit Run to fire this endpoint
GET
/api/traceroutes?limit=50
Traceroute history β hop-by-hop paths through the mesh
βΌ
About
JS Example
βΆ Try It
Returns recorded traceroute results β the hop-by-hop paths packets have taken through the mesh. Each entry has from_id, to_id, route (ordered array of intermediate node IDs), and timestamp. Tells you how many hops a route takes and which nodes are acting as relays.
JavaScript
const routes = await fetch( '/api/traceroutes?limit=20' ).then(r => r.json()); routes.forEach(r => { const hops = r.route_path?.length || 0; const dt = new Date(r.timestamp * 1000).toLocaleString(); console.log(`${r.from_id} β ${r.to_id}: ${hops} hops @ ${dt}`); });
Hit Run to fire this endpoint
GET
/api/waypoints
All waypoints broadcast on the mesh β named map markers with coordinates
βΌ
About
JS Example
βΆ Try It
Returns all waypoints broadcast on the mesh. Each has from_id, name, description, latitude, longitude, icon, and timestamp. Coordinates are already converted from Meshtastic's integer format to decimal degrees.
JavaScript β GeoJSON conversion
const waypoints = await fetch('/api/waypoints').then(r => r.json()); // Convert to GeoJSON FeatureCollection for Leaflet/Mapbox const geojson = { type: 'FeatureCollection', features: waypoints .filter(w => w.latitude && w.longitude) .map(w => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [w.longitude, w.latitude] }, properties: { name: w.name, desc: w.description } })) }; console.log(`${geojson.features.length} mappable waypoints`);
Hit Run to fire this endpoint
Local Node & Channels
GET
/api/local_node/full
Comprehensive local radio info β LoRa config, WiFi, GPS mode, reboot count
βΌ
About
JS Example
βΆ Try It
The most detailed endpoint for local node info. Goes beyond /api/status to include LoRa configuration (region, hop limit, TX power, modem preset), Bluetooth state, WiFi SSID, GPS mode, position broadcast interval, node info broadcast interval, and reboot count. Returns 503 if the radio isn't ready yet.
Key fields not in /api/status
lora_region, lora_hop_limit, lora_tx_power, lora_use_presetbluetooth_enabled, wifi_ssidgps_mode, position_broadcast_secs, node_info_broadcast_secsreboot_count, has_wifi, has_bluetoothJavaScript
const node = await fetch('/api/local_node/full').then(r => r.json()); console.log('LoRa region:', node.lora_region); console.log('Hop limit:', node.lora_hop_limit); console.log('TX power:', node.lora_tx_power, 'dBm'); console.log('Reboots:', node.reboot_count);
Hit Run to fire this endpoint
GET
/api/channels
Channel configuration β names, roles, PSK, uplink/downlink flags
βΌ
About
JS Example
βΆ Try It
Returns the channel configuration of the locally connected radio. Returns 503 if the radio is not connected. The PSK is returned as a base64 string β only share this over authenticated connections.
Response fields per channel
index β channel slot (0 = primary, 1β7 = secondary)name β human-readable channel namerole β "PRIMARY", "SECONDARY", or "DISABLED"psk β base64 PSK (keep private!)uplink / downlink β MQTT bridge flagsJavaScript β channel picker
const channels = await fetch('/api/channels').then(r => r.json()); // Get active (non-disabled) channels const active = channels.filter(c => c.role !== 'DISABLED'); // Build a channel picker dropdown const select = document.getElementById('channel-select'); active.forEach(ch => { const opt = document.createElement('option'); opt.value = ch.index; opt.textContent = `Ch ${ch.index}: ${ch.name}`; select.appendChild(opt); });
Hit Run to fire this endpoint
Node History
GET
/api/nodes/{node_id}/history/{table_name}
Per-node positions, telemetry, or packet history from the database
βΌ
About
βΆ Try It
table_name must be one of: positions, telemetry, packets. The positions and telemetry tables return timestamped rows perfect for time-series charting. The packets table returns raw decoded packet rows with decoded and raw as parsed JSON objects.Query Parameters
limitint Β· default: 1000 Β· max: 1000Max rows to return
startfloat Β· optionalUnix timestamp β only return rows after this time
endfloat Β· optionalUnix timestamp β only return rows before this time
Also available
GET /api/nodes/{node_id}/count/{item_type} β item_type: messages_sent, positions, or telemetrySupports optional
start / end unix timestampsReturns
{"node_id":β¦,"item_type":β¦,"count":β¦}Enter a node ID and select a table then hit Run
Metrics & Counts
GET
/api/metrics/averages?limit=100
Average SNR/RSSI/battery history β network health time series (5-min snapshots)
βΌ
About
JS Example
βΆ Try It
Returns the most recent averaged network metrics snapshot plus up to
limit historical snapshots. Each data point represents a 5-minute average snapshot across all active nodes. Shape: {"most_recent": {β¦}, "history": [{β¦}]}.Fields per snapshot
timestamp, average_snr, average_rssi, node_count24 points Γ 5 min = ~2 hours of history; 288 points = 24 hours
Perfect input for a Chart.js line chart showing RF quality over time
JavaScript β Chart.js line chart
const data = await fetch( '/api/metrics/averages?limit=48' ).then(r => r.json()); const history = data.history.reverse(); // oldest first const labels = history.map(h => new Date(h.timestamp * 1000).toLocaleTimeString() ); new Chart(canvas, { type: 'line', data: { labels, datasets: [ { label: 'Avg SNR (dB)', data: history.map(h => h.average_snr) }, { label: 'Avg RSSI (dBm)', data: history.map(h => h.average_rssi) } ] } });
Hit Run to fire this endpoint
GET
/api/counts/totals
Database record totals β messages, positions, telemetry
βΌ
About
βΆ Try It
Returns total database counts:
total_messages, total_positions, total_telemetry. Useful for plugin dashboards that need to show database size or data density. These counts survive restarts.Hit Run to fire this endpoint
System & Plugin Management
All system management endpoints require authentication AUTH.
SYSTEM
Plugin Registry & Management Endpoints
List, toggle, install, delete, and inspect plugins at runtime
AUTHβΌ
Endpoints
βΆ Try Registry
GET /api/system/plugins
Returns full plugin registry. Each entry has:
manifest, status (running/stopped/crashed/hung/pending_restart/invalid_manifest/loading), error, path, watchdog_monitored, last_watchdog_pingGET /api/system/plugins/menu
Returns nav menu items from all running plugins. Links to
/static/plugins/β¦ are auto-rewritten to /plugin/β¦ wrapper route.POST /api/system/plugins/{id}/toggle?action=start|stop
Soft start/stop a plugin.
stop writes a .disabled sentinel file. start removes it. Returns requires_restart: true if the plugin was previously crashed β Python modules cannot be hot-reloaded.DELETE /api/system/plugins/{id}
Permanently deletes the plugin folder with
shutil.rmtree. Irreversible. Returns requires_restart: true.POST /api/system/plugins/install
Installs a plugin from a
.zip file upload (multipart/form-data, field name file). Zip must contain valid manifest.json with watchdog field. Returns 409 if plugin ID already exists.POST /api/system/plugins/install-remote
Downloads and installs from a remote
.zip URL. Body: {"url":"https://β¦/plugin.zip"}. SSRF-protected: rejects RFC-1918 addresses.POST /api/system/restart
Gracefully shuts down, broadcasts a restart message to the mesh, then does an
os.execv self-restart. All plugins reload from scratch.GET / POST /api/system/config
GET returns the current running config as JSON. POST /api/system/config/update with changed keys hot-reloads most settings at runtime. Some changes (DB path, port) require restart.Hit Run to fetch the full plugin registry
Plugin Logs API
GET
/api/system/plugins/{plugin_id}/logs
Last 250 log lines from the plugin's memory ring buffer
AUTHβΌ
About
Response Shape
βΆ Try It
Returns the last 250 log lines from the plugin's named logger ring buffer. Also available:
DELETE /api/system/plugins/{id}/logs to clear the buffer β useful for debugging: clear, trigger your action, fetch again.Available endpoints
GET /api/system/plugins/{id}/logs β fetch the ring bufferDELETE /api/system/plugins/{id}/logs β clear the ring bufferLog entries are structured:
{"t": unix_ts, "lvl": "INFO", "msg": "β¦"}The Plugins page displays them colour-coded by level in the Logs modal
Response shape
{
"plugin_id": "hello_mesh",
"count": 12,
"max": 250,
"logs": [
{"t": 1718000000.0, "lvl": "INFO", "msg": "Plugin started"},
{"t": 1718000030.0, "lvl": "DEBUG", "msg": "Watchdog ping"},
{"t": 1718000031.0, "lvl": "WARNING", "msg": "Something odd"},
{"t": 1718000032.0, "lvl": "ERROR", "msg": "Something broke"}
]
}Hit Run to emit test log lines from hello_mesh then fetch the ring buffer
Web Monitor & Content Extract
POST
/extract & /api/monitor
Fetch a URL server-side, extract text blocks, optionally broadcast to mesh
AUTHβΌ
About
Parameters
Two related endpoints for web content extraction and mesh broadcasting.
POST /extract fetches a URL server-side and returns text blocks. POST /api/monitor does the same but automatically broadcasts a chosen block to the mesh.Both endpoints reject RFC-1918/loopback addresses (SSRF protection). Only public HTTPS URLs are accepted.
POST /extract
url REQUIRED β Public HTTPS URL to fetchblock_id optional β Return only a specific block by index. Omit for all blocks (up to 50)Returns
{"blocks": [{β¦}]}POST /api/monitor
url REQUIRED β Public URL to fetchblock_id REQUIRED β Which block (from /extract output) to broadcastprefix optional β Prepended to the text e.g. "News:"channel optional β Channel index. Default 0node_id optional β Target node for DM. Omit for broadcastText is truncated to 200 chars before transmitting
SSE β Live Event Stream
SSE
/sse
Server-Sent Events β live packets, node updates, stats pushed to the browser
βΌ
About
JS Example
βΆ Live Feed
Server-Sent Events stream. Connect and you immediately receive the current
connection_status and full nodes list as bootstrap events, then a continuous stream of live events as they occur. Keepalive ping every 30 s. Max 50 concurrent SSE clients. Each client gets their own bounded queue (200 events).The browser reconnects automatically on SSE disconnect β no need to handle reconnection manually. Bootstrap events re-fire on every connect so you always get a consistent starting state.
| event | data |
|---|---|
| connection_status | String β current connection state |
| nodes | JSON array of all known nodes (bootstrap snapshot) |
| packet | JSON object of a newly received/processed packet |
| node_update | JSON of updated node data |
| activity | "RX" or "TX" pulse |
| stats | JSON stats snapshot broadcast periodically |
JavaScript β complete SSE handler
const es = new EventSource('/sse'); // React to new packets the instant they arrive es.addEventListener('packet', e => { const pkt = JSON.parse(e.data); if (pkt.app_packet_type === 'Message') { addMessageToUI(pkt.fromId, pkt.decoded?.text, pkt.channel); } if (pkt.app_packet_type === 'Telemetry') { updateBatteryDisplay(pkt.fromId, pkt.decoded?.telemetry); } }); // Node position updates for live map es.addEventListener('node_update', e => { const node = JSON.parse(e.data); if (node.latitude && node.longitude) updateNodeOnMap(node.node_id, node.latitude, node.longitude); }); // Track radio connection state es.addEventListener('connection_status', e => { const status = JSON.parse(e.data); document.getElementById('status-badge').textContent = status; document.getElementById('status-badge').className = status === 'Connected' ? 'badge-green' : 'badge-red'; }); // Stats update periodically es.addEventListener('stats', e => { const stats = JSON.parse(e.data); document.getElementById('pkt-count').textContent = stats.packets_received_session; }); es.onerror = () => console.warn('SSE disconnected, auto-reconnectingβ¦');
Click Connect to open the SSE stream and see live events.