📡
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 Router Context Injection DatabaseManager Live Node Data Radio Connection SSE Real-Time Watchdog Heartbeat Plugin 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`.

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 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

limit int · default: 60 Max 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`);

limit
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_id string · required Node ID in !hex format, e.g. `!a1b2c3d4`
node_id
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

limit int · default: 50 Max packets to return
limit
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`);

limit
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_id string · optional Filter by sender node ID e.g. `!ab12cd34`
to_id string · optional Filter by recipient. Use `^all` for broadcasts.
channel int · default: 0 Channel index (0-7). Only used for broadcast messages.
limit int · default: 100 Max 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}`);
    });

limit
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)

message string REQUIRED Text string to transmit
channel int · default: 0 Channel index 0-7
destination string · optional Target 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

limit int · default: 1000 · max: 1000 Max rows to return
start float · optional Unix timestamp — only return rows after this time
end float · optional Unix 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":…}`
node_id
table
limit
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) }
            ]
        }
    });

limit
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.

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.
Checking connection… Hello Mesh v1.0.0 · MeshDash Plugin Reference