diff --git a/docs/api-spec.md b/docs/api-spec.md new file mode 100644 index 00000000..7b7dfabf --- /dev/null +++ b/docs/api-spec.md @@ -0,0 +1,1903 @@ +# 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](#conventions) +- [GET /api/stats](#get-apistats) +- [GET /api/health](#get-apihealth) +- [GET /api/perf](#get-apiperf) +- [POST /api/perf/reset](#post-apiperfreset) +- [GET /api/nodes](#get-apinodes) +- [GET /api/nodes/search](#get-apinodessearch) +- [GET /api/nodes/bulk-health](#get-apinodesbulk-health) +- [GET /api/nodes/network-status](#get-apinodesnetwork-status) +- [GET /api/nodes/:pubkey](#get-apinodespubkey) +- [GET /api/nodes/:pubkey/health](#get-apinodespubkeyhealth) +- [GET /api/nodes/:pubkey/paths](#get-apinodespubkeypaths) +- [GET /api/nodes/:pubkey/analytics](#get-apinodespubkeyanalytics) +- [GET /api/packets](#get-apipackets) +- [GET /api/packets/timestamps](#get-apipacketstimestamps) +- [GET /api/packets/:id](#get-apipacketsid) +- [POST /api/packets](#post-apipackets) +- [POST /api/decode](#post-apidecode) +- [GET /api/observers](#get-apiobservers) +- [GET /api/observers/:id](#get-apiobserversid) +- [GET /api/observers/:id/analytics](#get-apiobserversidanalytics) +- [GET /api/channels](#get-apichannels) +- [GET /api/channels/:hash/messages](#get-apichannelshashmessages) +- [GET /api/analytics/rf](#get-apianalyticsrf) +- [GET /api/analytics/topology](#get-apianalyticstopology) +- [GET /api/analytics/channels](#get-apianalyticschannels) +- [GET /api/analytics/distance](#get-apianalyticsdistance) +- [GET /api/analytics/hash-sizes](#get-apianalyticshash-sizes) +- [GET /api/analytics/subpaths](#get-apianalyticssubpaths) +- [GET /api/analytics/subpath-detail](#get-apianalyticssubpath-detail) +- [GET /api/resolve-hops](#get-apiresolve-hops) +- [GET /api/traces/:hash](#get-apitraceshash) +- [GET /api/config/theme](#get-apiconfigtheme) +- [GET /api/config/regions](#get-apiconfigregions) +- [GET /api/config/client](#get-apiconfigclient) +- [GET /api/config/cache](#get-apiconfigcache) +- [GET /api/config/map](#get-apiconfigmap) +- [GET /api/iata-coords](#get-apiiata-coords) +- [GET /api/audio-lab/buckets](#get-apiaudio-labbuckets) +- [WebSocket Messages](#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 `| null` may be absent or `null`. +- Array fields MUST be `[]` when empty, NEVER `null`. +- 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 + +```json +{ "error": "string" } +``` + +- `400` — Bad request (missing/invalid params) +- `404` — Resource not found + +--- + +## GET /api/stats + +Server-wide statistics. Lightweight, cached 10s. + +### Response `200` + +```jsonc +{ + "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` + +```jsonc +{ + "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` + +```jsonc +{ + "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 + } + } +} +``` + +--- + +## POST /api/perf/reset + +Resets performance counters. Requires API key. + +### Headers + +- `X-API-Key: ` (required if `config.apiKey` is set) + +### Response `200` + +```json +{ "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` + +```jsonc +{ + "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_seen` is only present when more than one hash size has been observed. +- `last_heard` is only present when in-memory data provides a more recent timestamp than `last_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` + +```jsonc +{ + "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): + +```jsonc +[ + { + "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` + +```jsonc +{ + "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` + +```jsonc +{ + "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](#packet-object)). + +### Response `404` + +```json +{ "error": "Not found" } +``` + +--- + +## GET /api/nodes/:pubkey/health + +Detailed health information for a single node. + +### Response `200` + +```jsonc +{ + "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` + +```json +{ "error": "Not found" } +``` + +--- + +## GET /api/nodes/:pubkey/paths + +Path analysis for a node — all paths containing this node's prefix. + +### Response `200` + +```jsonc +{ + "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` + +```json +{ "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` + +```jsonc +{ + "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` + +```json +{ "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) + +```jsonc +{ + "packets": [Packet], // see Packet Object below (observations stripped unless expand=observations) + "total": number, + "limit": number, + "offset": number +} +``` + +### Response `200` (groupByHash=true) + +```jsonc +{ + "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) + +```jsonc +{ + "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): + +```jsonc +["2025-07-17T00:00:01.000Z", "2025-07-17T00:00:02.000Z", ...] +``` + +### Response `400` + +```json +{ "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` + +```jsonc +{ + "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` + +```json +{ "error": "Not found" } +``` + +--- + +## POST /api/packets + +Ingest a raw packet. Requires API key. + +### Headers + +- `X-API-Key: ` (required if `config.apiKey` is set) + +### Request Body + +```jsonc +{ + "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` + +```jsonc +{ + "id": number, // packet/observation ID + "decoded": { // full decode result + "header": DecodedHeader, + "path": DecodedPath, + "payload": object + } +} +``` + +### Response `400` + +```json +{ "error": "hex is required" } +``` + +--- + +## POST /api/decode + +Decode a raw packet without storing it. + +### Request Body + +```jsonc +{ + "hex": string // required — raw hex-encoded packet +} +``` + +### Response `200` + +```jsonc +{ + "decoded": { + "header": DecodedHeader, + "path": DecodedPath, + "payload": object + } +} +``` + +### Response `400` + +```json +{ "error": "hex is required" } +``` + +--- + +## GET /api/observers + +List all observers with packet counts. + +### Response `200` + +```jsonc +{ + "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` + +```jsonc +{ + "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` + +```json +{ "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` + +```jsonc +{ + "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` + +```jsonc +{ + "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` + +```jsonc +{ + "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` + +```jsonc +{ + "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 + +```jsonc +{ + "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` + +```jsonc +{ + "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_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` + +```jsonc +{ + "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` + +```jsonc +{ + "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` + +```jsonc +{ + "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` + +```jsonc +{ + "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` + +```jsonc +{ + "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` + +```jsonc +{ + "resolved": { + "": { + "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:** + +```jsonc +{ + "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` + +```jsonc +{ + "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` + +```jsonc +{ + "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` + +```jsonc +{ + "": 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` + +```jsonc +{ + "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: + +```jsonc +{ + "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` + +```jsonc +{ + "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` + +```jsonc +{ + "coords": { + "": { + "lat": number, + "lon": number, + "radiusKm": number + } + } +} +``` + +--- + +## GET /api/audio-lab/buckets + +Representative packets bucketed by payload type for audio lab. + +### Response `200` + +```jsonc +{ + "buckets": { + "": [ + { + "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://` (or `wss://` for HTTPS). No authentication. +The server broadcasts messages to all connected clients. + +### Message Wrapper + +All WebSocket messages use this envelope: + +```jsonc +{ + "type": string, // "packet" or "message" + "data": object // payload (shape depends on type) +} +``` + +### Message Type: `"packet"` + +Broadcast on every new packet ingestion. + +```jsonc +{ + "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.decoded` is always present with at least `header.payloadTypeName`. +- `data.packet` is included for raw packet ingestion (Format 1 / MQTT), may be absent for companion bridge messages. +- `data.path_json` is the JSON-stringified version of `data.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. + +```jsonc +{ + "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: + +```jsonc +{ + "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: + +```jsonc +{ + "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 + +```jsonc +{ + "routeType": number, + "payloadType": number, + "payloadVersion": number, + "payloadTypeName": string // human-readable name +} +``` + +### DecodedPath + +```jsonc +{ + "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) |