#191: Hash collision matrix now filters to role=repeater only (routing-relevant) #192: expand=observations in /api/packets now returns full observation details (txToMap includes observations, stripped by default) #193: /api/nodes/:pubkey/health uses in-memory PacketStore when available instead of slow SQL queries #194: goRuntime (heapMB, sysMB, numGoroutine, numGC, gcPauseMs) restored in /api/perf response Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
52 KiB
MeshCore Analyzer — API Contract Specification
Authoritative contract. Both the Node.js and Go backends MUST conform to this spec. The frontend relies on these exact shapes. Breaking changes require a spec update first.
Version: 1.0.0 Last updated: 2025-07-17
Table of Contents
- Conventions
- GET /api/stats
- GET /api/health
- GET /api/perf
- POST /api/perf/reset
- GET /api/nodes
- GET /api/nodes/search
- GET /api/nodes/bulk-health
- GET /api/nodes/network-status
- GET /api/nodes/:pubkey
- GET /api/nodes/:pubkey/health
- GET /api/nodes/:pubkey/paths
- GET /api/nodes/:pubkey/analytics
- GET /api/packets
- GET /api/packets/timestamps
- GET /api/packets/:id
- POST /api/packets
- POST /api/decode
- GET /api/observers
- GET /api/observers/:id
- GET /api/observers/:id/analytics
- GET /api/channels
- GET /api/channels/:hash/messages
- GET /api/analytics/rf
- GET /api/analytics/topology
- GET /api/analytics/channels
- GET /api/analytics/distance
- GET /api/analytics/hash-sizes
- GET /api/analytics/subpaths
- GET /api/analytics/subpath-detail
- GET /api/resolve-hops
- GET /api/traces/:hash
- GET /api/config/theme
- GET /api/config/regions
- GET /api/config/client
- GET /api/config/cache
- GET /api/config/map
- GET /api/iata-coords
- GET /api/audio-lab/buckets
- WebSocket Messages
Conventions
Types
| Notation | Meaning |
|---|---|
string |
JSON string |
number |
JSON number (integer or float) |
boolean |
true / false |
string (ISO) |
ISO 8601 timestamp, e.g. "2025-07-17T04:23:01.000Z" |
string (hex) |
Hex-encoded bytes, uppercase, e.g. "4F01A3..." |
number | null |
May be null when data is unavailable |
[T] |
JSON array of type T; always [] when empty, never null |
object |
Nested JSON object (shape defined inline) |
Null Rules
- Fields marked
| nullmay be absent ornull. - Array fields MUST be
[]when empty, NEVERnull. - String fields that are "unknown" SHOULD be
null, not"".
Pagination
Paginated endpoints accept limit (default 50) and offset (default 0) as query params.
They return total (the unfiltered/filtered count before pagination).
Error Responses
{ "error": "string" }
400— Bad request (missing/invalid params)404— Resource not found
GET /api/stats
Server-wide statistics. Lightweight, cached 10s.
Response 200
{
"totalPackets": number, // observation count (legacy name)
"totalTransmissions": number | null, // unique transmission count
"totalObservations": number, // total observation records
"totalNodes": number, // active nodes (last 7 days)
"totalNodesAllTime": number, // all nodes ever seen
"totalObservers": number, // observer device count
"packetsLastHour": number, // observations in last hour
"engine": "node", // backend engine identifier
"version": string, // package.json version, e.g. "2.6.0"
"commit": string, // git short SHA or "unknown"
"counts": {
"repeaters": number, // active repeaters (last 7 days)
"rooms": number,
"companions": number,
"sensors": number
}
}
GET /api/health
Server health and telemetry. Used by monitoring.
Response 200
{
"status": "ok",
"engine": "node",
"version": string,
"commit": string,
"uptime": number, // seconds
"uptimeHuman": string, // e.g. "4h 32m"
"memory": {
"rss": number, // MB
"heapUsed": number, // MB
"heapTotal": number, // MB
"external": number // MB
},
"eventLoop": {
"currentLagMs": number,
"maxLagMs": number,
"p50Ms": number,
"p95Ms": number,
"p99Ms": number
},
"cache": {
"entries": number,
"hits": number,
"misses": number,
"staleHits": number,
"recomputes": number,
"hitRate": number // percentage (0–100)
},
"websocket": {
"clients": number // connected WS clients
},
"packetStore": {
"packets": number, // loaded transmissions
"estimatedMB": number
},
"perf": {
"totalRequests": number,
"avgMs": number,
"slowQueries": number,
"recentSlow": [ // last 5
{
"path": string,
"ms": number,
"time": string, // ISO timestamp
"status": number // HTTP status
}
]
}
}
GET /api/perf
Detailed performance metrics per endpoint.
Response 200
{
"uptime": number, // seconds since perf stats reset
"totalRequests": number,
"avgMs": number,
"endpoints": {
"/api/packets": { // keyed by route path
"count": number,
"avgMs": number,
"p50Ms": number,
"p95Ms": number,
"maxMs": number
}
// ... more endpoints
},
"slowQueries": [ // last 20 queries > 100ms
{
"path": string,
"ms": number,
"time": string, // ISO timestamp
"status": number
}
],
"cache": {
"size": number,
"hits": number,
"misses": number,
"staleHits": number,
"recomputes": number,
"hitRate": number // percentage (0–100)
},
"packetStore": { // from PacketStore.getStats()
"totalLoaded": number,
"totalObservations": number,
"evicted": number,
"inserts": number,
"queries": number,
"inMemory": number,
"sqliteOnly": boolean,
"maxPackets": number,
"estimatedMB": number,
"maxMB": number,
"indexes": {
"byHash": number,
"byObserver": number,
"byNode": number,
"advertByObserver": number
}
},
"sqlite": {
"dbSizeMB": number,
"walSizeMB": number,
"freelistMB": number,
"walPages": { "total": number, "checkpointed": number, "busy": number } | null,
"rows": {
"transmissions": number,
"observations": number,
"nodes": number,
"observers": number
}
},
"goRuntime": { // Go server only
"heapMB": number, // heap allocation in MB
"sysMB": number, // total system memory in MB
"numGoroutine": number, // active goroutines
"numGC": number, // completed GC cycles
"gcPauseMs": number // last GC pause in ms
}
}
POST /api/perf/reset
Resets performance counters. Requires API key.
Headers
X-API-Key: <key>(required ifconfig.apiKeyis set)
Response 200
{ "ok": true }
GET /api/nodes
Paginated node list with filtering.
Query Parameters
| Param | Type | Default | Description |
|---|---|---|---|
limit |
number | 50 |
Page size |
offset |
number | 0 |
Pagination offset |
role |
string | — | Filter by role: repeater, room, companion, sensor |
region |
string | — | Comma-separated IATA codes for regional filtering |
lastHeard |
string | — | Recency filter: 1h, 6h, 24h, 7d, 30d |
sortBy |
string | lastSeen |
Sort key: name, lastSeen, packetCount |
search |
string | — | Substring match on name |
before |
string | — | ISO timestamp; only nodes with first_seen <= before |
Response 200
{
"nodes": [
{
"public_key": string, // 64-char hex public key
"name": string | null,
"role": string, // "repeater" | "room" | "companion" | "sensor"
"lat": number | null,
"lon": number | null,
"last_seen": string (ISO),
"first_seen": string (ISO),
"advert_count": number,
"hash_size": number | null, // latest hash size (1–3 bytes)
"hash_size_inconsistent": boolean, // true if flip-flopping
"hash_sizes_seen": [number] | undefined, // present only if >1 unique size seen
"last_heard": string (ISO) | undefined // from in-memory packets or path relay
}
],
"total": number, // total matching count (before pagination)
"counts": {
"repeaters": number, // global counts (not filtered by current query)
"rooms": number,
"companions": number,
"sensors": number
}
}
Notes:
hash_sizes_seenis only present when more than one hash size has been observed.last_heardis only present when in-memory data provides a more recent timestamp thanlast_seen.
GET /api/nodes/search
Quick node search for autocomplete/typeahead.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
q |
string | yes | Search term (name substring or pubkey prefix) |
Response 200
{
"nodes": [
{
"public_key": string,
"name": string | null,
"role": string,
"lat": number | null,
"lon": number | null,
"last_seen": string (ISO),
"first_seen": string (ISO),
"advert_count": number
}
]
}
Returns { "nodes": [] } when q is empty.
GET /api/nodes/bulk-health
Bulk health summary for all nodes. Used by analytics dashboard.
Query Parameters
| Param | Type | Default | Description |
|---|---|---|---|
limit |
number | 50 |
Max nodes (capped at 200) |
region |
string | — | Comma-separated IATA codes for regional filtering |
Response 200
Returns a JSON array (not wrapped in an object):
[
{
"public_key": string,
"name": string | null,
"role": string,
"lat": number | null,
"lon": number | null,
"stats": {
"totalTransmissions": number,
"totalObservations": number,
"totalPackets": number, // same as totalTransmissions (backward compat)
"packetsToday": number,
"avgSnr": number | null,
"lastHeard": string (ISO) | null
},
"observers": [
{
"observer_id": string,
"observer_name": string | null,
"avgSnr": number | null,
"avgRssi": number | null,
"packetCount": number
}
]
}
]
Note: This is a bare array, not { nodes: [...] }.
GET /api/nodes/network-status
Aggregate network health status counts.
Query Parameters
| Param | Type | Default | Description |
|---|---|---|---|
region |
string | — | Comma-separated IATA codes |
Response 200
{
"total": number,
"active": number, // within degradedMs threshold
"degraded": number, // between degradedMs and silentMs
"silent": number, // beyond silentMs
"roleCounts": {
"repeater": number,
"room": number,
"companion": number,
"sensor": number
// may include "unknown" if role is missing
}
}
GET /api/nodes/:pubkey
Node detail page data.
Path Parameters
| Param | Type | Description |
|---|---|---|
pubkey |
string | Node public key (hex) |
Response 200
{
"node": {
"public_key": string,
"name": string | null,
"role": string,
"lat": number | null,
"lon": number | null,
"last_seen": string (ISO),
"first_seen": string (ISO),
"advert_count": number,
"hash_size": number | null,
"hash_size_inconsistent": boolean,
"hash_sizes_seen": [number] | undefined
},
"recentAdverts": [Packet] // last 20 packets for this node, newest first
}
Where Packet is a transmission object (see Packet Object).
Response 404
{ "error": "Not found" }
GET /api/nodes/:pubkey/health
Detailed health information for a single node.
Response 200
{
"node": { // full node row
"public_key": string,
"name": string | null,
"role": string,
"lat": number | null,
"lon": number | null,
"last_seen": string (ISO),
"first_seen": string (ISO),
"advert_count": number
},
"observers": [
{
"observer_id": string,
"observer_name": string | null,
"packetCount": number,
"avgSnr": number | null,
"avgRssi": number | null,
"iata": string | null
}
],
"stats": {
"totalTransmissions": number,
"totalObservations": number,
"totalPackets": number, // same as totalTransmissions (backward compat)
"packetsToday": number,
"avgSnr": number | null,
"avgHops": number, // rounded integer
"lastHeard": string (ISO) | null
},
"recentPackets": [ // last 20 packets, observations stripped
{
// Packet fields (see Packet Object) minus `observations`
"observation_count": number // added for display
}
]
}
Response 404
{ "error": "Not found" }
GET /api/nodes/:pubkey/paths
Path analysis for a node — all paths containing this node's prefix.
Response 200
{
"node": {
"public_key": string,
"name": string | null,
"lat": number | null,
"lon": number | null
},
"paths": [
{
"hops": [
{
"prefix": string, // raw hex hop prefix
"name": string, // resolved node name
"pubkey": string | null,
"lat": number | null,
"lon": number | null
}
],
"count": number, // times this path was seen
"lastSeen": string (ISO) | null,
"sampleHash": string // hash of a sample packet using this path
}
],
"totalPaths": number, // unique path signatures
"totalTransmissions": number // total transmissions with this node in path
}
Response 404
{ "error": "Not found" }
GET /api/nodes/:pubkey/analytics
Per-node analytics over a time range.
Query Parameters
| Param | Type | Default | Description |
|---|---|---|---|
days |
number | 7 |
Lookback window (1–365) |
Response 200
{
"node": { // full node row (same shape as nodes table)
"public_key": string, "name": string | null, "role": string,
"lat": number | null, "lon": number | null,
"last_seen": string (ISO), "first_seen": string (ISO), "advert_count": number
},
"timeRange": {
"from": string (ISO),
"to": string (ISO),
"days": number
},
"activityTimeline": [
{ "bucket": string (ISO), "count": number } // hourly buckets
],
"snrTrend": [
{
"timestamp": string (ISO),
"snr": number,
"rssi": number | null,
"observer_id": string | null,
"observer_name": string | null
}
],
"packetTypeBreakdown": [
{ "payload_type": number, "count": number }
],
"observerCoverage": [
{
"observer_id": string,
"observer_name": string | null,
"packetCount": number,
"avgSnr": number | null,
"avgRssi": number | null,
"firstSeen": string (ISO),
"lastSeen": string (ISO)
}
],
"hopDistribution": [
{ "hops": string, "count": number } // "0", "1", "2", "3", "4+"
],
"peerInteractions": [
{
"peer_key": string,
"peer_name": string,
"messageCount": number,
"lastContact": string (ISO)
}
],
"uptimeHeatmap": [
{ "dayOfWeek": number, "hour": number, "count": number } // 0=Sun, 0–23
],
"computedStats": {
"availabilityPct": number, // 0–100
"longestSilenceMs": number,
"longestSilenceStart": string (ISO) | null,
"signalGrade": string, // "A", "A-", "B+", "B", "C", "D"
"snrMean": number,
"snrStdDev": number,
"relayPct": number, // % of packets with >1 hop
"totalPackets": number,
"uniqueObservers": number,
"uniquePeers": number,
"avgPacketsPerDay": number
}
}
Response 404
{ "error": "Not found" }
GET /api/packets
Paginated packet (transmission) list with filtering.
Query Parameters
| Param | Type | Default | Description |
|---|---|---|---|
limit |
number | 50 |
Page size |
offset |
number | 0 |
Pagination offset |
type |
string | — | Filter by payload type (number or name) |
route |
string | — | Filter by route type |
region |
string | — | Filter by region (IATA code substring) |
observer |
string | — | Filter by observer ID |
hash |
string | — | Filter by packet hash |
since |
string | — | ISO timestamp lower bound |
until |
string | — | ISO timestamp upper bound |
node |
string | — | Filter by node pubkey |
nodes |
string | — | Comma-separated pubkeys (multi-node filter) |
order |
string | DESC |
Sort direction: asc or desc |
groupByHash |
string | — | Set to "true" for grouped response |
expand |
string | — | Set to "observations" to include observation arrays |
Response 200 (default)
{
"packets": [Packet], // see Packet Object below (observations stripped unless expand=observations)
"total": number,
"limit": number,
"offset": number
}
Response 200 (groupByHash=true)
{
"packets": [
{
"hash": string,
"first_seen": string (ISO),
"count": number, // observation count
"observer_count": number, // unique observers
"latest": string (ISO),
"observer_id": string | null,
"observer_name": string | null,
"path_json": string | null,
"payload_type": number,
"route_type": number,
"raw_hex": string (hex),
"decoded_json": string | null,
"observation_count": number,
"snr": number | null,
"rssi": number | null
}
],
"total": number
}
Response 200 (nodes=... multi-node)
{
"packets": [Packet],
"total": number,
"limit": number,
"offset": number
}
GET /api/packets/timestamps
Lightweight endpoint returning only timestamps for timeline sparklines.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
since |
string | yes | ISO timestamp lower bound |
Response 200
Returns a JSON array of timestamps (strings or numbers):
["2025-07-17T00:00:01.000Z", "2025-07-17T00:00:02.000Z", ...]
Response 400
{ "error": "since required" }
GET /api/packets/:id
Single packet detail with byte breakdown and observations.
Path Parameters
| Param | Type | Description |
|---|---|---|
id |
string | Packet ID (numeric) or 16-char hex hash |
Response 200
{
"packet": Packet, // full packet/transmission object
"path": [string], // parsed path hops (from packet.paths or [])
"breakdown": { // byte-level packet structure
"ranges": [
{
"start": number, // byte offset
"end": number,
"label": string,
"hex": string,
"value": string | number | null
}
]
} | null,
"observation_count": number,
"observations": [
{
"id": number,
"transmission_id": number,
"hash": string,
"observer_id": string | null,
"observer_name": string | null,
"direction": string | null,
"snr": number | null,
"rssi": number | null,
"score": number | null,
"path_json": string | null,
"timestamp": string (ISO),
"raw_hex": string (hex),
"payload_type": number,
"decoded_json": string | null,
"route_type": number
}
]
}
Response 404
{ "error": "Not found" }
POST /api/packets
Ingest a raw packet. Requires API key.
Headers
X-API-Key: <key>(required ifconfig.apiKeyis set)
Request Body
{
"hex": string, // required — raw hex-encoded packet
"observer": string | null, // observer ID
"snr": number | null,
"rssi": number | null,
"region": string | null, // IATA code
"hash": string | null // pre-computed content hash
}
Response 200
{
"id": number, // packet/observation ID
"decoded": { // full decode result
"header": DecodedHeader,
"path": DecodedPath,
"payload": object
}
}
Response 400
{ "error": "hex is required" }
POST /api/decode
Decode a raw packet without storing it.
Request Body
{
"hex": string // required — raw hex-encoded packet
}
Response 200
{
"decoded": {
"header": DecodedHeader,
"path": DecodedPath,
"payload": object
}
}
Response 400
{ "error": "hex is required" }
GET /api/observers
List all observers with packet counts.
Response 200
{
"observers": [
{
"id": string,
"name": string | null,
"iata": string | null, // region code
"last_seen": string (ISO),
"first_seen": string (ISO),
"packet_count": number,
"model": string | null, // hardware model
"firmware": string | null,
"client_version": string | null,
"radio": string | null,
"battery_mv": number | null, // millivolts
"uptime_secs": number | null,
"noise_floor": number | null, // dBm
"packetsLastHour": number, // computed, not from DB
"lat": number | null, // from matched node
"lon": number | null, // from matched node
"nodeRole": string | null // from matched node
}
],
"server_time": string (ISO) // server's current time
}
GET /api/observers/:id
Single observer detail.
Response 200
{
"id": string,
"name": string | null,
"iata": string | null,
"last_seen": string (ISO),
"first_seen": string (ISO),
"packet_count": number,
"model": string | null,
"firmware": string | null,
"client_version": string | null,
"radio": string | null,
"battery_mv": number | null,
"uptime_secs": number | null,
"noise_floor": number | null,
"packetsLastHour": number
}
Response 404
{ "error": "Observer not found" }
GET /api/observers/:id/analytics
Per-observer analytics.
Query Parameters
| Param | Type | Default | Description |
|---|---|---|---|
days |
number | 7 |
Lookback window |
Response 200
{
"timeline": [
{ "label": string, "count": number } // bucketed by hours/days
],
"packetTypes": {
"4": number, // keyed by payload_type number
"5": number
},
"nodesTimeline": [
{ "label": string, "count": number } // unique nodes per time bucket
],
"snrDistribution": [
{ "range": string, "count": number } // e.g. "6 to 8"
],
"recentPackets": [Packet] // last 20 enriched observations
}
GET /api/channels
List decoded channels with message counts.
Query Parameters
| Param | Type | Default | Description |
|---|---|---|---|
region |
string | — | Comma-separated IATA codes |
Response 200
{
"channels": [
{
"hash": string, // channel name (used as key)
"name": string, // decoded channel name
"lastMessage": string | null, // text of most recent message
"lastSender": string | null, // sender of most recent message
"messageCount": number,
"lastActivity": string (ISO)
}
]
}
GET /api/channels/:hash/messages
Messages for a specific channel.
Path Parameters
| Param | Type | Description |
|---|---|---|
hash |
string | Channel name (from /api/channels) |
Query Parameters
| Param | Type | Default | Description |
|---|---|---|---|
limit |
number | 100 |
Page size |
offset |
number | 0 |
Pagination offset (from end) |
Response 200
{
"messages": [
{
"sender": string,
"text": string,
"timestamp": string (ISO),
"sender_timestamp": number | null, // device timestamp (unreliable)
"packetId": number,
"packetHash": string,
"repeats": number, // dedup count
"observers": [string], // observer names
"hops": number,
"snr": number | null
}
],
"total": number // total deduplicated messages
}
GET /api/analytics/rf
RF signal analytics.
Query Parameters
| Param | Type | Default | Description |
|---|---|---|---|
region |
string | — | Comma-separated IATA codes |
Response 200
{
"totalPackets": number, // observations with SNR data
"totalAllPackets": number, // all regional observations
"totalTransmissions": number, // unique transmission hashes
"snr": {
"min": number,
"max": number,
"avg": number,
"median": number,
"stddev": number
},
"rssi": {
"min": number,
"max": number,
"avg": number,
"median": number,
"stddev": number
},
"snrValues": Histogram, // pre-computed histogram (20 bins)
"rssiValues": Histogram, // pre-computed histogram (20 bins)
"packetSizes": Histogram, // pre-computed histogram (25 bins)
"minPacketSize": number, // bytes
"maxPacketSize": number,
"avgPacketSize": number,
"packetsPerHour": [
{ "hour": string, "count": number } // "2025-07-17T04"
],
"payloadTypes": [
{ "type": number, "name": string, "count": number }
],
"snrByType": [
{ "name": string, "count": number, "avg": number, "min": number, "max": number }
],
"signalOverTime": [
{ "hour": string, "count": number, "avgSnr": number }
],
"scatterData": [
{ "snr": number, "rssi": number } // max 500 points
],
"timeSpanHours": number
}
Histogram Shape
{
"bins": [
{ "x": number, "w": number, "count": number }
],
"min": number,
"max": number
}
GET /api/analytics/topology
Network topology analytics.
Query Parameters
| Param | Type | Default | Description |
|---|---|---|---|
region |
string | — | Comma-separated IATA codes |
Response 200
{
"uniqueNodes": number,
"avgHops": number,
"medianHops": number,
"maxHops": number,
"hopDistribution": [
{ "hops": number, "count": number } // capped at 25
],
"topRepeaters": [
{
"hop": string, // raw hex prefix
"count": number,
"name": string | null, // resolved name
"pubkey": string | null
}
],
"topPairs": [
{
"hopA": string,
"hopB": string,
"count": number,
"nameA": string | null,
"nameB": string | null,
"pubkeyA": string | null,
"pubkeyB": string | null
}
],
"hopsVsSnr": [
{ "hops": number, "count": number, "avgSnr": number }
],
"observers": [
{ "id": string, "name": string }
],
"perObserverReach": {
"<observer_id>": {
"observer_name": string,
"rings": [
{
"hops": number,
"nodes": [
{
"hop": string,
"name": string | null,
"pubkey": string | null,
"count": number,
"distRange": string | null // e.g. "1-3" or null if constant
}
]
}
]
}
},
"multiObsNodes": [
{
"hop": string,
"name": string | null,
"pubkey": string | null,
"observers": [
{
"observer_id": string,
"observer_name": string,
"minDist": number,
"count": number
}
]
}
],
"bestPathList": [
{
"hop": string,
"name": string | null,
"pubkey": string | null,
"minDist": number,
"observer_id": string,
"observer_name": string
}
]
}
GET /api/analytics/channels
Channel analytics.
Query Parameters
| Param | Type | Default | Description |
|---|---|---|---|
region |
string | — | Comma-separated IATA codes |
Response 200
{
"activeChannels": number,
"decryptable": number,
"channels": [
{
"hash": string,
"name": string,
"messages": number,
"senders": number, // unique sender count
"lastActivity": string (ISO),
"encrypted": boolean
}
],
"topSenders": [
{ "name": string, "count": number }
],
"channelTimeline": [
{ "hour": string, "channel": string, "count": number }
],
"msgLengths": [number] // raw array of message character lengths
}
GET /api/analytics/distance
Hop distance analytics.
Query Parameters
| Param | Type | Default | Description |
|---|---|---|---|
region |
string | — | Comma-separated IATA codes |
Response 200
{
"summary": {
"totalHops": number,
"totalPaths": number,
"avgDist": number, // km, 2 decimal places
"maxDist": number // km
},
"topHops": [
{
"fromName": string,
"fromPk": string,
"toName": string,
"toPk": string,
"dist": number, // km
"type": string, // "R↔R" | "C↔R" | "C↔C"
"snr": number | null,
"hash": string,
"timestamp": string (ISO)
}
],
"topPaths": [
{
"hash": string,
"totalDist": number, // km
"hopCount": number,
"timestamp": string (ISO),
"hops": [
{
"fromName": string,
"fromPk": string,
"toName": string,
"toPk": string,
"dist": number
}
]
}
],
"catStats": {
"R↔R": { "count": number, "avg": number, "median": number, "min": number, "max": number },
"C↔R": { "count": number, "avg": number, "median": number, "min": number, "max": number },
"C↔C": { "count": number, "avg": number, "median": number, "min": number, "max": number }
},
"distHistogram": Histogram | [], // empty array if no data
"distOverTime": [
{ "hour": string, "avg": number, "count": number }
]
}
GET /api/analytics/hash-sizes
Hash size analysis across the network.
Query Parameters
| Param | Type | Default | Description |
|---|---|---|---|
region |
string | — | Comma-separated IATA codes |
Response 200
{
"total": number, // packets analyzed
"distribution": {
"1": number, // 1-byte hash count
"2": number, // 2-byte hash count
"3": number // 3-byte hash count
},
"hourly": [
{ "hour": string, "1": number, "2": number, "3": number }
],
"topHops": [
{
"hex": string, // raw hop hex
"size": number, // bytes (ceil(hex.length/2))
"count": number,
"name": string | null,
"pubkey": string | null
}
],
"multiByteNodes": [
{
"name": string,
"hashSize": number,
"packets": number,
"lastSeen": string (ISO),
"pubkey": string | null
}
]
}
GET /api/analytics/subpaths
Subpath frequency analysis.
Query Parameters
| Param | Type | Default | Description |
|---|---|---|---|
minLen |
number | 2 |
Minimum subpath length (≥2) |
maxLen |
number | 8 |
Maximum subpath length |
limit |
number | 100 |
Max results |
region |
string | — | Comma-separated IATA codes |
Response 200
{
"subpaths": [
{
"path": string, // "Node A → Node B → Node C"
"rawHops": [string], // ["aa", "bb", "cc"]
"count": number,
"hops": number, // length of subpath
"pct": number // percentage of totalPaths (0–100)
}
],
"totalPaths": number
}
GET /api/analytics/subpath-detail
Detailed stats for a specific subpath.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
hops |
string | yes | Comma-separated raw hex hop prefixes |
Response 200
{
"hops": [string], // input hops echoed back
"nodes": [
{
"hop": string,
"name": string,
"lat": number | null,
"lon": number | null,
"pubkey": string | null
}
],
"totalMatches": number,
"firstSeen": string (ISO) | null,
"lastSeen": string (ISO) | null,
"signal": {
"avgSnr": number | null,
"avgRssi": number | null,
"samples": number
},
"hourDistribution": [number], // 24-element array (index = UTC hour)
"parentPaths": [
{ "path": string, "count": number }
],
"observers": [
{ "name": string, "count": number }
]
}
GET /api/resolve-hops
Resolve path hop hex prefixes to node names with regional disambiguation.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
hops |
string | yes | Comma-separated hex hop prefixes |
observer |
string | no | Observer ID for regional context |
originLat |
number | no | Origin latitude for distance-based disambiguation |
originLon |
number | no | Origin longitude |
Response 200
{
"resolved": {
"<hop>": {
"name": string | null,
"pubkey": string | null,
"ambiguous": boolean | undefined, // true if multiple candidates
"unreliable": boolean | undefined, // true if failed sanity check
"candidates": [Candidate],
"conflicts": [Candidate],
"globalFallback": boolean | undefined,
"filterMethod": string | undefined, // "geo" | "observer"
"hopBytes": number | undefined, // for ambiguous entries
"totalGlobal": number | undefined,
"totalRegional": number | undefined,
"filterMethods": [string] | undefined
}
},
"region": string | null
}
Candidate shape:
{
"name": string,
"pubkey": string,
"lat": number | null,
"lon": number | null,
"regional": boolean,
"filterMethod": string,
"distKm": number | null
}
GET /api/traces/:hash
All observations of a specific packet hash, sorted chronologically.
Path Parameters
| Param | Type | Description |
|---|---|---|
hash |
string | Packet hash |
Response 200
{
"traces": [
{
"observer": string | null, // observer_id
"observer_name": string | null,
"time": string (ISO),
"snr": number | null,
"rssi": number | null,
"path_json": string | null
}
]
}
GET /api/config/theme
Theme and branding configuration (merged from config.json + theme.json).
Response 200
{
"branding": {
"siteName": string, // default: "MeshCore Analyzer"
"tagline": string // default: "Real-time MeshCore LoRa mesh network analyzer"
// ... additional branding keys from config/theme files
},
"theme": {
"accent": string, // hex color, default "#4a9eff"
"accentHover": string,
"navBg": string,
"navBg2": string
// ... additional theme CSS values
},
"themeDark": {
// dark mode overrides (may be empty object)
},
"nodeColors": {
"repeater": string, // hex color
"companion": string,
"room": string,
"sensor": string,
"observer": string
},
"typeColors": {
// payload type → hex color overrides
},
"home": object | null // home page customization
}
GET /api/config/regions
Available regions (IATA codes) merged from config + DB.
Response 200
{
"<iata_code>": string // code → display name
// e.g. "SFO": "San Francisco", "LAX": "Los Angeles"
}
Returns a flat key-value object.
GET /api/config/client
Client-side configuration values.
Response 200
{
"roles": object | null,
"healthThresholds": object | null,
"tiles": object | null,
"snrThresholds": object | null,
"distThresholds": object | null,
"maxHopDist": number | null,
"limits": object | null,
"perfSlowMs": number | null,
"wsReconnectMs": number | null,
"cacheInvalidateMs": number | null,
"externalUrls": object | null,
"propagationBufferMs": number // default: 5000
}
GET /api/config/cache
Cache TTL configuration (raw values in seconds).
Response 200
Returns the raw cacheTTL object from config.json, or {} if not set:
{
"stats": number | undefined, // seconds
"nodeDetail": number | undefined,
"nodeHealth": number | undefined,
"nodeList": number | undefined,
"bulkHealth": number | undefined,
"networkStatus": number | undefined,
"observers": number | undefined,
"channels": number | undefined,
"channelMessages": number | undefined,
"analyticsRF": number | undefined,
"analyticsTopology": number | undefined,
"analyticsChannels": number | undefined,
"analyticsHashSizes": number | undefined,
"analyticsSubpaths": number | undefined,
"analyticsSubpathDetail": number | undefined,
"nodeAnalytics": number | undefined,
"nodeSearch": number | undefined,
"invalidationDebounce": number | undefined
}
GET /api/config/map
Map default center and zoom.
Response 200
{
"center": [number, number], // [lat, lon], default [37.45, -122.0]
"zoom": number // default 9
}
GET /api/iata-coords
IATA airport/region coordinates for client-side regional filtering.
Response 200
{
"coords": {
"<iata_code>": {
"lat": number,
"lon": number,
"radiusKm": number
}
}
}
GET /api/audio-lab/buckets
Representative packets bucketed by payload type for audio lab.
Response 200
{
"buckets": {
"<type_name>": [
{
"hash": string,
"raw_hex": string (hex),
"decoded_json": string | null,
"observation_count": number,
"payload_type": number,
"path_json": string | null,
"observer_id": string | null,
"timestamp": string (ISO)
}
]
}
}
WebSocket Messages
Connection
Connect to ws://<host> (or wss://<host> for HTTPS). No authentication.
The server broadcasts messages to all connected clients.
Message Wrapper
All WebSocket messages use this envelope:
{
"type": string, // "packet" or "message"
"data": object // payload (shape depends on type)
}
Message Type: "packet"
Broadcast on every new packet ingestion.
{
"type": "packet",
"data": {
"id": number, // observation or transmission ID
"raw": string (hex) | null,
"decoded": {
"header": {
"routeType": number,
"payloadType": number,
"payloadVersion": number,
"payloadTypeName": string // "ADVERT", "GRP_TXT", "TXT_MSG", etc.
},
"path": {
"hops": [string] // hex hop prefixes
},
"payload": object // decoded payload (varies by type)
},
"snr": number | null,
"rssi": number | null,
"hash": string | null,
"observer": string | null, // observer_id
"observer_name": string | null,
"path_json": string | null, // JSON-stringified hops array
"packet": Packet | undefined, // full packet object (when available)
"observation_count": number | undefined
}
}
Notes:
data.decodedis always present with at leastheader.payloadTypeName.data.packetis included for raw packet ingestion (Format 1 / MQTT), may be absent for companion bridge messages.data.path_jsonis the JSON-stringified version ofdata.decoded.path.hops.
Fields consumed by frontend pages:
| Field | live.js | packets.js | app.js | channels.js |
|---|---|---|---|---|
data.id |
✓ | ✓ | ||
data.hash |
✓ | ✓ | ||
data.raw |
✓ | |||
data.decoded.header.payloadTypeName |
✓ | ✓ | ||
data.decoded.payload |
✓ | ✓ | ||
data.decoded.path.hops |
✓ | |||
data.snr |
✓ | |||
data.rssi |
✓ | |||
data.observer |
✓ | |||
data.observer_name |
✓ | |||
data.packet |
✓ | |||
data.observation_count |
✓ | |||
data.path_json |
✓ | |||
| (any) | ✓ (*) |
(*) app.js passes all messages to registered wsListeners and uses them only for cache invalidation.
Message Type: "message"
Broadcast for GRP_TXT (channel message) packets only. Same data shape as "packet" type.
channels.js listens for this type to update the channel message feed in real time.
{
"type": "message",
"data": {
// identical shape to "packet" data
}
}
Shared Object Shapes
Packet Object
A transmission/packet as stored in memory and returned by most endpoints:
{
"id": number, // transmission ID
"raw_hex": string (hex) | null,
"hash": string, // content hash (dedup key)
"first_seen": string (ISO), // when first observed
"timestamp": string (ISO), // display timestamp (= first_seen)
"route_type": number, // 0=DIRECT, 1=FLOOD, 2=reserved, 3=TRANSPORT
"payload_type": number, // 0=REQ, 1=RESPONSE, 2=TXT_MSG, 3=ACK, 4=ADVERT, 5=GRP_TXT, 7=ANON_REQ, 8=PATH, 9=TRACE, 11=CONTROL
"payload_version": number | null,
"decoded_json": string | null, // JSON-stringified decoded payload
"observation_count": number,
"observer_id": string | null, // from "best" observation
"observer_name": string | null,
"snr": number | null,
"rssi": number | null,
"path_json": string | null, // JSON-stringified hop array
"direction": string | null,
"score": number | null,
"observations": [Observation] | undefined // stripped by default on list endpoints
}
Observation Object
A single observation of a transmission by an observer:
{
"id": number,
"transmission_id": number,
"hash": string,
"observer_id": string | null,
"observer_name": string | null,
"direction": string | null,
"snr": number | null,
"rssi": number | null,
"score": number | null,
"path_json": string | null,
"timestamp": string (ISO) | number, // ISO string or unix epoch
// Enriched fields (from parent transmission):
"raw_hex": string (hex) | null,
"payload_type": number,
"decoded_json": string | null,
"route_type": number
}
DecodedHeader
{
"routeType": number,
"payloadType": number,
"payloadVersion": number,
"payloadTypeName": string // human-readable name
}
DecodedPath
{
"hops": [string], // hex hop prefixes, e.g. ["a1b2", "c3d4"]
"hashSize": number, // bytes per hop hash (1–3)
"hashCount": number // number of hops in path field
}
Payload Type Reference
| Value | Name | Description |
|---|---|---|
| 0 | REQ |
Request |
| 1 | RESPONSE |
Response |
| 2 | TXT_MSG |
Direct text message |
| 3 | ACK |
Acknowledgement |
| 4 | ADVERT |
Node advertisement |
| 5 | GRP_TXT |
Group/channel text message |
| 7 | ANON_REQ |
Anonymous request |
| 8 | PATH |
Path / traceroute |
| 9 | TRACE |
Trace response |
| 11 | CONTROL |
Control message |
Route Type Reference
| Value | Name | Description |
|---|---|---|
| 0 | DIRECT |
Direct (with transport codes) |
| 1 | FLOOD |
Flood/broadcast |
| 2 | (reserved) | |
| 3 | TRANSPORT |
Transport (with transport codes) |