SSE Events Reference
MeshDash uses Server-Sent Events (SSE) for real-time communication between the server and the browser. Events are pushed from the server to the client with no polling required. The frontend maintains a single EventSource connection to the active slot's SSE stream.
SSE Endpoints
| Endpoint | Description | Connect-Time Events |
|---|---|---|
/sse | Primary slot (node_0) stream. Legacy endpoint. | connection_status → nodes (chunked at 500) |
/sse/{slot_id} | Per-slot stream (e.g. /sse/node_1). | connection_status → stats → local_node_info → nodes (chunked) |
/sse/all | Multiplexed stream from all slots. Nodes stamped with heard_by_slot. | connection_status → merged nodes from all slots (chunked) |
Message Format
Every SSE message follows the structured event format — data is a JSON-encoded string:
{
"event": "<event_name>",
"data": "<json_string>"
}
Connection & Reconnection
| Side | Behaviour |
|---|---|
| Server | Keepalive ping every 30s of inactivity. sse-starlette also pings at 15s. Client limit: MAX_SSE_CLIENTS = 50 (HTTP 503 beyond). Dead clients removed from queue. |
| Frontend | Exponential backoff: 1s → 2s → 4s → ... → 16s max. Reconnects on visibility change, online event, or error. Closes EventSource on beforeunload. |
Slot ID Stamping
When broadcast_data() is called with a slot_id other than "node_0", the data is tagged with "heard_by_slot": slot_id. Dicts containing node_id get the key injected directly; lists get it injected into each item. This lets the frontend know which radio produced each event in multi-slot mode.
Event Catalogue — All 17 Event Types
connection_status
Fires: On SSE connect (initial burst), on every connection state transition, WebSerial status changes, MeshCore/MQTT connection events.
{
"state": "connected", // idle|connecting|connected|reconnecting|degraded|disconnected|webserial|mqtt
"detail": "Connected (TCP)", // Human-readable detail
"transport": "TCP", // Active transport
"label": "Connected (TCP)" // Backward-compatible field
}
core/data.py:611, core/routes/slot_routes.py (all 3 endpoints), core/webserial.py_sseHandlers.connectionStatus — updates meshState.connectionStatus + meshState.connectionStatelocal_node_info
Fires: On slot connect (initial burst for /sse/{slot_id}), when local node info is set/refreshed/cleared, MeshCore/MQTT connection discovery.
{
"node_id": "!a1b2c3d4",
"node_num": 2712849364,
"hardware_model": "T_ECHO",
"firmware_version": "2.5.7",
"long_name": "My Node",
"short_name": "MYN",
"macaddr": "aa:bb:cc:dd:ee:ff",
"role": "CLIENT",
"region": "EU_868",
"max_channels": 8,
"latitude": 50.7842,
"longitude": -1.0735,
"altitude": 15,
"battery_level": 87,
"voltage": 3.98,
"lora_region": "EU_868",
"lora_hop_limit": 3,
"channels_json": "[...]",
"channel_count": 5,
"nodedb_count": 42,
"last_updated": 1716393600.0
}
core/data.py:501 (primary), core/data.py:346 (clear), core/packet.py:257 (refresh), core/connections/meshcore.py:373, core/connections/mqtt.py:777/sse/{slot_id}, /sse/all; not in /sse initial burst_sseHandlers.localNodeInfo — sets meshState.local_node_idnodes
Fires: On every SSE connect (initial burst — full node list) and after successful background sync.
[
{
"node_id": "!a1b2c3d4",
"node_num": 2712849364,
"isLocal": true,
"longName": "My Node",
"shortName": "MYN",
"lastHeard": 1716393600,
"snr": 12.5,
"rssi": -45,
"position": {"latitude": 50.78, "longitude": -1.07},
"deviceMetrics": {"batteryLevel": 87},
"environmentMetrics": {...},
"hw_model": "T_ECHO",
"role": "CLIENT",
"user": {...},
"heard_by_slot": "node_1"
},
...
]
Chunking: If >500 nodes, first 500 go in the nodes event, remainder in node_batch events.
core/routes/slot_routes.py (all 3 endpoints), core/sync.py:79 (after sync)_sseHandlers.nodes — full replace (single-slot) or merge (multi-slot)node_batch
Fires: On SSE connect when node list exceeds 500 entries — subsequent chunks after initial nodes. Same format as nodes — array of node objects, max 500 per chunk.
core/routes/slot_routes.py (all 3 SSE generators)_sseHandlers.nodeBatch — incremental merge, preserves heard_by_slotnode_update
Fires: Every time MeshtasticData.update_node() is called with broadcast=True — happens on virtually every received packet. Debounced at 2000ms on frontend to coalesce rapid per-packet updates.
{
"node_id": "!a1b2c3d4",
"lastHeard": 1716393600,
"snr": 12.5,
"rssi": -45,
"source": "RF_DIRECT",
"source_confidence": 0.95,
"position": {"latitude": 50.78, "longitude": -1.07},
"deviceMetrics": {"batteryLevel": 87},
"user": {"longName": "Some Node", "shortName": "SOM"},
"heard_by_slot": "node_1"
}
core/data.py:335_sseHandlers.nodeUpdate — merges into meshState.nodes[nodeId]packet
Fires: After _packet_processing_worker_for_slot processes every packet. Every non-"Unknown"/"Raw:*" packet gets broadcast.
{
"id": 123456789,
"fromId": "!a1b2c3d4",
"toId": "^all",
"channel": 0,
"rxTime": 1716393600,
"rxSnr": 12.5,
"rxRssi": -45,
"hopLimit": 3,
"hopStart": 7,
"decoded": {
"portnum": "TEXT_MESSAGE_APP",
"text": "Hello mesh!",
"payload": "Hello mesh!",
"mesh_packet_id": 123456789
},
"app_packet_type": "Message",
"source": "RF_DIRECT",
"source_confidence": 0.95,
"slot_id": "node_1",
"heard_by_slot": "node_1",
"timestamp": 1716393600.0,
"original_channel_id": 0,
"encrypted": false
}
app_packet_type values: Message, Position, Telemetry, Node Info, Ack, Routing Error, Neighbor Info, Traceroute, Paxcounter, Detection, Range Test, Serial, Store & Forward, Waypoint, Admin, Encrypted, or Raw: <port_name>.
core/packet.py:30 — after add_packet()_sseHandlers.packet — anti-duplication, prepends to meshState.packets (max 500), flashes RX LED, feeds MeshShark, comms, terminalstats
Fires: On SSE slot connect (/sse/{slot_id} initial burst) and after every processed packet.
{
"packets_received_session": 1234,
"text_messages_session": 456,
"position_updates_session": 200,
"telemetry_reports_session": 100,
"user_info_updates_session": 30,
"waypoint_updates_session": 5,
"other_packets_session": 50,
"start_time": 1716393600.0,
"nodes_seen_session": 42,
"channels_seen_session": 3,
"elapsed_time_session": 3600.5
}
core/broadcast.py:89 (legacy node_0), core/broadcast.py:93 (per-slot), core/packet.py:32 (per-packet)_sseHandlers.stats — accumulate (multi-slot) or replace (single-slot)activity
Fires: On every packet receive (on_fast_rx callback — fast-path before queue) and every packet send (on_fast_tx callback).
"RX"
or
"TX"
core/packet.py:122 (RX), core/packet.py:130 (TX)_sseHandlers.activity — flashes LED indicatorsync_status
Fires: During background node database sync — start, progress, and completion notifications.
{ "is_syncing": true, "current": "Syncing 42 nodes..." }
{ "is_syncing": false, "current": "Connected" }
core/sync.py:26,39,95,111,141_sseHandlers.syncStatus — shows progress indicatorerror
Fires: When MeshtasticData.set_error() is called with a connection/system error.
"Connection refused"
core/data.py:683_sseHandlers.serverError — logs to terminalsystem_update
Fires: C2 system messages — link up/down notifications, status changes.
{ "message": "✅ <b>Link Up:</b> Node !a1b2c3d4 online. HW: T-ECHO" }
core/c2.py:867_sseHandlers.systemUpdate — delegates to terminalsystem_message
Fires: System restart notifications, version check results, update download completion.
{ "message": "🔄 System restarting..." }
core/routes/system_routes.py:317,383,399,411,423,508message_status_update
Fires: When ACK or routing error is received for a previously-sent message.
{ "mesh_packet_id": 12345, "status": "DELIVERED" }
status values: "DELIVERED" (ACK received) or "FAILED" (routing error).
core/database.py:596 — ACK/routing error matching_sseHandlers. Registered directly by DM view (dmes.js:127) and map (map.js:1637)traceroute_result
Fires: After a traceroute request completes or times out with a result.
{
"route": ["!a1b2c3d4", "!e5f6g7h8"],
"routeBack": ["!e5f6g7h8", "!a1b2c3d4"],
"snrTowards": [12.5, 8.0],
"snrBack": [9.0, 11.0]
}
core/routes/node_routes.py:230 — after traceroute completesslot_id=req.slot_id)_sseHandlers.tracerouteResult — delegates to traceroute appplugin_update
Fires: Plugin toggle (start/stop), auto-recovery attempts, background worker status changes.
{ "id": "auto_reply_plugin", "status": "running" }
status values: "running", "stopped", "pending_restart", "hung".
core/routes/plugin_routes.py:295,311,328, core/plugin_manager.py:510,525, core/background.py:180,195_sseHandlers.pluginUpdate — dispatches CustomEvent('plugin_update_sse')ping
Fires: Every 30s of SSE inactivity (server-side keepalive). Also sent by sse-starlette at 15s intervals.
{}
core/routes/slot_routes.py (all 3 generators), sse-starlette_sseHandlers.ping — no-op, updates lastEventAt for watchdogFrontend Handler Map
| Event | Handler Key | In _sseHandlers? | Also Registered Elsewhere? |
|---|---|---|---|
ping | ping | ✅ | — |
connection_status | connectionStatus | ✅ | — |
stats | stats | ✅ | — |
local_node_info | localNodeInfo | ✅ | — |
nodes | nodes | ✅ | — |
node_batch | nodeBatch | ✅ | — |
node_update | nodeUpdate | ✅ | — |
system_update | systemUpdate | ✅ | — |
packet | packet | ✅ | — |
activity | activity | ✅ | — |
error | serverError | ✅ | — |
sync_status | syncStatus | ✅ | — |
traceroute_result | tracerouteResult | ✅ | — |
plugin_update | pluginUpdate | ✅ | — |
message_status_update | — | ❌ | dmes.js:127, map.js:1637 |
system_message | — | ❌ | Nowhere — event is silently dropped |
Constants & Limits
| Constant | Value | Location |
|---|---|---|
MAX_SSE_CLIENTS | 50 | core/globals.py:20 |
| Per-client queue (slot) | 200 | core/routes/slot_routes.py |
| Per-client queue (all) | 500 | core/routes/slot_routes.py |
| Node chunk size | 500 | core/routes/slot_routes.py |
| Server ping interval | 30s | core/routes/slot_routes.py |
| sse-starlette ping | 15s | EventSourceResponse(gen(), ping=15) |
| Frontend base backoff | 1000ms | app.js |
| Frontend max backoff | 16000ms | app.js |
| Frontend max packets | 500 | app.js _sseHandlers.packet |
See also: Plugin Development for broadcasting custom SSE events. Frontend Architecture for how the SSE stream drives the UI.