πŸ“‘
Hello Mesh
Plugin Developer Reference & API Explorer Β· MeshDash Plugin System
● Live v1.0.0 Read-Only

πŸ“‘ 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 watchdog field
2
main.py
Must expose plugin_router (a FastAPI APIRouter) and init_plugin(context)
3
Context Injection
MeshDash calls init_plugin() in a daemon thread, injecting db_manager, meshtastic_data, connection_manager, event_loop, logger, watchdog dict
4
static/ folder
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 /api/plugins/{your_id}/your_route β€” a 503 guard is applied when stopped
6
Watchdog
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.html

What the core does at startup

Scans plugins/ for subdirectories containing manifest.json
Validates the manifest β€” rejects any plugin missing the watchdog key
Mounts the plugin's static/ folder at static_prefix
Imports the entry_point Python file and mounts plugin_router at router_prefix
Calls init_plugin(context) inside a daemon thread with 15 s timeout
manifest.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.
FieldTypeDescription
id REQUIREDstringUnique identifier. [a-zA-Z0-9_-]+ only. Used as the key in PLUGIN_REGISTRY and in all URLs.
name REQUIREDstringHuman-readable display name shown in Plugins page and nav menu.
version optionalstringSemantic version string e.g. "1.0.0". Shown in the Plugins page.
author optionalstringAuthor name shown in the Plugins page.
description optionalstringShort description shown in the Plugins page card.
entry_point REQUIREDstringFilename of the Python file to import. Usually "main.py". Relative to the plugin folder.
router_prefix REQUIREDstringURL prefix for all plugin_router routes. E.g. "/api/plugins/my_plugin". A 503 guard is applied when plugin is stopped.
static_prefix REQUIREDstringURL prefix where the plugin's static/ folder is mounted. E.g. "/static/plugins/my_plugin".
watchdog REQUIREDbooleantrue = plugin must heartbeat every ≀120 s or be marked hung. false = never monitored. Must be explicitly present.
nav_menu optionalarrayArray 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.
KeyDefaultDescription
MESHTASTIC_HOST192.168.0.0IP/hostname of the Meshtastic device (TCP mode).
MESHTASTIC_PORT4403TCP port of the Meshtastic device.
MESHTASTIC_CONNECTION_TYPESERIALSERIAL, TCP, or BLE.
MESHTASTIC_SERIAL_PORTemptySerial port path e.g. /dev/ttyUSB0. Leave empty to auto-detect.
MESHTASTIC_BLE_MACemptyBLE MAC address for BLE connection mode.
WEBSERVER_HOST0.0.0.0Host interface uvicorn binds to.
WEBSERVER_PORT8000HTTP port uvicorn listens on.
DB_PATHmeshtastic_data.dbPath to the main SQLite database.
TASK_DB_PATHtasks.dbPath to the tasks/scheduler SQLite database.
MAX_PACKETS_MEMORY200Max packets kept in the in-process ring buffer.
HISTORY_DAYS1Days of average metrics history to keep.
LOG_LEVELINFOLogging verbosity: DEBUG, INFO, WARNING, ERROR.
AUTH_SECRET_KEYrandomJWT signing secret. Change this in production.
AUTH_TOKEN_EXPIRE_MINUTES30How long session tokens are valid.
COMMUNITY_APIfalseEnable the community C2 / heartbeat channel.
COMMUNITY_API_KEYplaceholderAPI key for the community C2 service.
HEARTBEAT_INTERVAL_MINUTES1How often the core sends a C2 heartbeat if enabled.
SEND_LOCAL_NODE_LOCATIONtrueInclude local node GPS in C2 heartbeat.
SEND_OTHER_NODES_LOCATIONtrueInclude neighbour node GPS in C2 heartbeat.
LOCATION_OFFSET_ENABLEDfalseApply a random GPS offset to all reported positions.
LOCATION_OFFSET_METERS0.0Max offset radius in metres when offset is enabled.
C2_ACCESS_LEVELreadC2 permission tier: read, write, or admin.
C2_MAX_REQUESTS_PER_SYNC10Max commands processed per C2 sync cycle.
C2_SYNC_INTERVAL_SECONDS15How often the C2 worker polls for queued commands.
INITIAL_ADMIN_USERNAMEemptyUsed only once during first-run setup. Auto-deleted after use.
INITIAL_ADMIN_PASSWORDemptyAs 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.
KeyTypeDescription
db_managerDatabaseManagerDirect access to the MeshDash SQLite layer. Provides get_all_nodes(), get_messages(), get_recent_packets(), etc. Use asyncio.to_thread() in async routes.
meshtastic_dataMeshtasticDataIn-memory state object. .nodes, .local_node_info, .connection_status, .packets. Fast dict lookups β€” no to_thread needed.
connection_managerMeshtasticConnectionManagerThe radio interface. await cm.sendText(msg, destinationId, channelIndex). Always check cm.is_ready.is_set() before sending.
event_loopasyncio.AbstractEventLoopThe live uvicorn event loop. Use with run_coroutine_threadsafe() to schedule async tasks from inside init_plugin.
loggerlogging.LoggerNamespaced logger (plugin.<id>). Output captured in 250-line ring buffer accessible via GET /api/system/plugins/<id>/logs.
plugin_watchdogdict[str, float]Shared watchdog dict. Write context["plugin_watchdog"][context["plugin_id"]] = time.time() every ≀120 s to stay alive.
plugin_idstrYour 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 timestamp
On 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 lines
DELETE /api/system/plugins/{plugin_id}/logs β€” clears the ring buffer
Each 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 commands
connection_status β€” human-readable: "Connected", "Reconnecting", "Initializing", etc.
local_node_info β€” your radio: node_id, long_name, hardware_model, firmware_version, channels
last_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_session
telemetry_reports_session, elapsed_time_session (seconds), nodes_seen_session
Also accessible as mesh_data.get_serializable_stats() from plugin Python code
Hit 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, rssi
battery_level, voltage, latitude, longitude, altitude
isLocal: true on the node that is your directly connected radio
Use Object.values(nodes) to iterate in JavaScript
JavaScript β€” 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. !a1b2c3d4
Enter 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 payload
Position β€” GPS coordinates with altitude, precision, satellite count
Telemetry β€” battery, voltage, channel utilisation, environment sensors
Node Info β€” node identity broadcast (name, hardware model, role)
Traceroute, Paxcounter, Range Test
JavaScript β€” 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), timestamp
Useful 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. !ab12cd34
to_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 ^all
Response: {"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_preset
bluetooth_enabled, wifi_ssid
gps_mode, position_broadcast_secs, node_info_broadcast_secs
reboot_count, has_wifi, has_bluetooth
JavaScript
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 name
role β€” "PRIMARY", "SECONDARY", or "DISABLED"
psk β€” base64 PSK (keep private!)
uplink / downlink β€” MQTT bridge flags
JavaScript β€” 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 telemetry
Supports optional start / end unix timestamps
Returns {"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_count
24 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_ping

GET /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 buffer
DELETE /api/system/plugins/{id}/logs β€” clear the ring buffer
Log 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 fetch
block_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 fetch
block_id REQUIRED β€” Which block (from /extract output) to broadcast
prefix optional β€” Prepended to the text e.g. "News:"
channel optional β€” Channel index. Default 0
node_id optional β€” Target node for DM. Omit for broadcast
Text 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.
eventdata
connection_statusString β€” current connection state
nodesJSON array of all known nodes (bootstrap snapshot)
packetJSON object of a newly received/processed packet
node_updateJSON of updated node data
activity"RX" or "TX" pulse
statsJSON 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.
Checking connection… Hello Mesh v1.0.0 Β· MeshDash Plugin Reference