Files
meshcore-analyzer/docs/api-spec.md
T
efiten 8c164c5315 feat(scope-audit): verify a declared region against the repeater's own traffic (#1990)
Follow-up to #1987, and the point of counting that traffic in the first
place.

A region this instance holds no `hashRegions` key for is **unnameable,
not absent**. #1987 says so with a caveat chip. This settles it wherever
the evidence allows: derive `SHA256("#region")[:16]` from the repeater's
own declaration and HMAC that repeater's own unmatched packets with it.
Same computation the ingestor performs at ingest, with the candidate set
narrowed from every configured key to this repeater's handful of
declarations.

Where it fires, a grey "declared but not observed" chip becomes a green
one and the caveat count shrinks by the packets it explained.

## Two packets, not one

`code1` is two bytes, so an unrelated name matches a given packet with
probability 1/65536. Across ~400 unmatched packets and ~124 declared
names, chance alone produces roughly one false match per refresh. Two
matches on the same region for the same repeater is (1/65536)², about
one in four billion.

Lowering the threshold to one would not make this noisy, it would make
it **unsound**, so the constant carries that arithmetic and a test
rather than a comment. A region with exactly one hit stays grey and
reports its single hit, so the page can say why it is still shown as not
observed instead of leaving the reader to wonder.

## What it deliberately does not do

**It writes nothing.** Read-time only. A wrong answer expires with the
window instead of sitting in `transmissions.scope_name` until someone
runs a repair, and `cmd/server` stays read-only per the invariant in
AGENTS.md.

**`notObserved` remains the single source of chip colour.**
`regionEvidence` says only HOW a region was established. Two fields that
can disagree about the same fact is how this column got confusing in the
first place.

## Rule 0, including the part that was wrong at first

The naive shape is `targets × names × packets` HMACs: 205 × 124 × 400 ≈
10M.

Caching per `(region, transmission)` pair cuts the HMACs to ~50k. **That
measured 501ms**, because the HMACs had become a rounding error while
the *iteration* stayed cubic at 10.2M map lookups. Re-keyed per region,
holding the set of matching transmissions, it is **36ms** at the same
worst-case shape: a region is HMACed over every packet once, and a
target then asks one question per declared region instead of one per
(region, packet). Most declared regions match nothing, so the common
case is a single map lookup and no packet loop at all.

`hmacCount` exists so a test can assert the first mistake cannot come
back; the benchmark exists because only it caught the second.

## Both axes are bounded, because neither is bounded by the data

The "~400 packets in a 7 day window" this was sized against is a
property of one instance's configuration, not of the feature: the
ingestor stores the unnameable state for every transport-scoped packet
no configured key names, so an instance with few or no `hashRegions`
entries — the stock state, and the one this helps most — has **every**
scoped packet in that set.

- the window query takes the 4096 most recent candidates and reports
truncation, which the handler logs, so a grey chip on a sampled refresh
is not read as "not forwarded"
- the declared list is capped at 32 names per repeater: it arrives from
a collector that validates each entry's shape but never how many entries
there are
- measured at the cap: **306ms** for 205 targets over 124 names, against
29ms for the shape a real network produces

Because both caps make the evidence a sample, the response carries
`observedUnmatchedSampled`. Without it a client subtracts a capped
numerator from an uncapped total and overstates the unexplained traffic
with no way to know it is doing so. The chip subtracts only evidence for
regions **absent** from `notObserved` — a single-hit region the server
refused to accept is not called explained either — and says "at most N"
when the count was sampled.

## Verified on live data

Six repeaters clear the threshold in a 7d window on a real instance. One
of them: `nl-nb` green with 3 corroborating packets and the tooltip
stating the count, `belml` still grey on 1, and the caveat chip reading
31 of 34 packets unexplained rather than 30.

## Tests

`scope_verify_test.go` covers the HMAC-input walk against a real
transport-flood packet captured from a live instance (a hand-built
fixture would only prove the parser agrees with itself), that
`regionCode` does not fold case, the threshold in both directions, the
memo's HMAC count, both bounds with their truncation flag, and the
benchmark at cap size. Handler-level tests cover a region verified into
green, a single hit left grey with its count reported, and the
sample-size field.

`cd cmd/server && go test ./...` passes (77s), frontend 723 assertions
pass, `go vet` and `gofmt -l` clean.
2026-09-09 17:58:09 +02:00

72 KiB
Raw Blame History

CoreScope — 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.1.0 Last updated: 2026-04-22


Table of Contents


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

{ "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 if config.apiKey is 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
area string — Area key from config.json — filters to nodes whose GPS falls inside the area polygon (see Area Filter)
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
      "default_scope": string | null | undefined // Most recently observed transport scope for this node. null = never observed transport-scoped, "" = observed scoped but no configured region matched, "#name" = matched region. Only present when ingestor has applied the nodes_default_scope_v1 migration.
    }
  ],
  "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

{
  "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/nodes/:pubkey/reach

Per-node RF reach report (two-way link quality). Computes directional link counts from raw path adjacency (a flood path is recorded origin→observer, so in [A,B] B received A directly). A link is bidirectional when both directions have observations; the bottleneck (weaker direction) rates two-way stability. Read-only; bounded to a recent window. Identifies nodes only by unique 2–3 byte path prefixes (1-byte prefixes collide and are excluded).

Query Parameters

Param Type Default Description
days number 7 Lookback window, clamped 1–30

Response 200

{
  "node": { "pubkey": string, "name": string, "role": string,
            "lat": number | null, "lon": number | null, "first_seen": string (ISO) },
  "window": { "days": number, "since": string (ISO) },
  "reliable_tokens": [string],          // uppercase hex prefixes unique to this node ([] if unidentifiable)
  "importance": {
    "neighbor_degree":    number,        // all-time, from neighbor_edges
    "degree_rank":        number,        // 1-based rank among nodes with edges
    "nodes_with_edges":   number,
    "relay_observations": number,        // windowed obs with this node anywhere in path
    "bidirectional_links":number,
    "direct_observers":   number
  },
  "direct_observers": [
    { "pubkey": string, "name": string, "count": number,
      "avg_snr": number | null, "lat": number | null, "lon": number | null,
      "distance_km": number | null }
  ],
  "links": [
    { "pubkey": string, "name": string, "role": string,
      "lat": number | null, "lon": number | null,
      "we_hear": number, "they_hear": number,
      "bottleneck": number, "bidir": boolean,
      "distance_km": number | null }
  ]
}

reliable_tokens: [] means the node has no unique 1–3 byte prefix and cannot be reliably identified in paths; links/direct_observers will be empty.

Caching & limits

  • Response cache: computed responses are cached for 5 minutes per pubkey|days. Polling faster than that returns an identical body — clients should not expect sub-5-minute freshness.
  • Scan cap: the windowed path scan is hard-capped at 200,000 rows. A node with more matching observations in the window is truncated (counts become a representative sample rather than exhaustive).

Response 400

Returned when :pubkey is not a 64-char hex string.

{ "error": "invalid pubkey: expected 64 hex chars" }

Response 404

Returned when the node is unknown or blacklisted.

{ "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/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
area string — Area key — restricts to packets whose transmitter GPS falls in the area (ADVERT packets only; see Area Filter)

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
area string — Area key — only hops that resolve to nodes inside the area are counted in repeater/pair frequency tables

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
area string — Area key — area filtering is supported but not exposed in the dashboard (channel stats are observer-based)

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
area string — Area key — restricts distance calculations to paths where the transmitter GPS falls in the area

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
area string — Area key — restricts to packets from nodes in the area

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/hash-collisions

Hash collision analysis — packets where the same hash was used by multiple different nodes (ambiguous routing).

Query Parameters

Param Type Default Description
region string — Comma-separated IATA codes
area string — Area key — restricts to packets from nodes in the area

Response 200

{
  "collisions": [
    {
      "hash":     string,           // hop hex prefix that collides
      "count":    number,           // number of distinct nodes sharing this prefix
      "nodes": [
        {
          "pubkey": string,
          "name":   string | null,
          "count":  number          // observation count for this node
        }
      ]
    }
  ],
  "totalCollisions": number,
  "affectedPackets": number
}

GET /api/nodes/clock-skew

Fleet-wide clock skew data. Returns all nodes for which clock skew has been calculated from ADVERT timestamp pairs.

Query Parameters

Param Type Default Description
area string — Area key — restricts to nodes whose GPS falls in the area

Response 200

Returns a JSON array (not wrapped in an object):

[
  {
    "pubkey":         string,
    "nodeName":       string | null,
    "nodeRole":       string | null,
    "skewMs":         number | null,          // current estimated clock offset (ms)
    "driftPerDaySec": number | null,          // drift rate (seconds/day)
    "severity":       string,                 // "good" | "warning" | "critical"
    "samples":        null                    // always null in fleet response (too large)
  }
]

Note: This is a bare array, not { nodes: [...] }.


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/scope-audit

Network-wide declared-vs-observed region-scope comparison: for every repeater that has ever successfully answered a declared-regions request, which of its declared regions have no observed forwarding in the window, which scopes it forwards without declaring, and whether it contradicts its own '*' wildcard. This is the whole-network answer to the question GET /api/nodes/:pubkey/scopes answers one repeater at a time — see that endpoint's notes for the full explanation of the three traps this comparison has to get right (the #-prefix spelling difference, '*' not being a scope, and "never asked" not being the same as "declared nothing"), which apply here identically.

Query Parameters

Param Type Default Description
window string 24h Time window: 1h, 24h, 7d

Response 200

{
  "window": string,                    // echoed window ("1h", "24h", or "7d")
  "since":  string (ISO),              // start of the observed-forwarding window
  "repeaters": [
    {
      "publicKey":        string,
      "name":             string,      // "" if the node row is gone (pruned/deleted)
      "role":             string,      // "" if unknown
      "declaredRegions":  [string],    // '*' excluded — see declaredWildcard
      "declaredWildcard": boolean,     // '*' present in the raw declared list
      "configState":      string,      // "full" | "no-scopes" | "no-unscoped" | "no-flood" — see note below
      "declaredAt":       string (ISO),// age of the DECLARED answer, not bounded by window
      "truncated":        boolean,     // declared list may have had entries silently dropped

      "notObserved":            [string],            // declared regions with zero matched-forwarding observed this window
      "undeclaredObserved":     [
        { "scope": string, "packets": number, "firstSeen": string (ISO), "lastSeen": string (ISO) }
      ],
      "observedUnscopedPackets": number,               // plain-FLOOD packets forwarded this window
      "wildcardContradiction":   boolean,               // observed unscoped forwarding but '*' not declared
      "ambiguousHops":            number,                // forwarder hops this window that could not be attributed — see note below
      "observedUnmatchedPackets": number,                // forwarded packets whose scope this instance holds no key for — see note below
      "observedUnmatchedSampled": number,                // how many of those verification could examine — see note below
      "regionEvidence":           { "<region>": number } // declared regions corroborated by this repeater's own unnameable traffic — see note below
    }
  ]
}

Response 400

{ "error": "window must be 1h, 24h, or 7d" }

Notes:

  • Only repeaters with at least one declared-regions answer appear in repeaters. A repeater that was never successfully asked is absent, not shown as a row that declares nothing — those are different facts (see GET /api/nodes/:pubkey/scopes's "never asked" note).

  • window bounds notObserved / undeclaredObserved / observedUnscopedPackets only — declaredAt is always the latest declared reading regardless of age, exactly like declared.observedAt on the per-node endpoint. A "declared but not observed" result is weak evidence at window=1h (a quiet region can simply have had no traffic) and much stronger at window=7d; a client MUST show which window a result belongs to and must not present a 1h result as if it were 7d. Because declaredAt is not bounded by window, the declared answer and the forwarding evidence are not guaranteed to be temporally aligned — a repeater could have declared its regions well before, or even after, the window that produced notObserved — and the server makes no attempt to align them; the declaredAt column in the UI is the mitigation (so a stale declared answer is visible to the reader), not a guarantee that the two sides describe the same period.

  • ambiguousHops counts forwarder-hop observations in this window whose truncated hash prefix matched more than one declared target's pubkey — i.e. two or more repeaters that declared a region list happen to share that prefix. Such a hop is attributed to none of the matching targets (crediting all of them risks papering over a real gap in a colliding neighbour's row; crediting none of them invents no failure) and this counter is incremented on every matching target instead. A row with a non-zero ambiguousHops carries weaker evidence than one with zero: any entry in that row's notObserved could be explained by a prefix collision rather than a genuine absence of forwarding, and a client should present it as a caveat rather than a confirmed finding.

  • observedUnmatchedPackets counts packets this repeater was observed forwarding whose transport scope matched no region key this instance has configured (hashRegions), so the ingestor stored them with an empty scope_name. Those packets name no region and therefore cannot satisfy a declared one, which means a repeater forwarding a region this instance cannot name is reported exactly like one forwarding nothing. A non-zero value is a caveat on this row's notObserved, in the same spirit as ambiguousHops but with a different cause and a different fix: ambiguousHops is a pubkey-prefix collision between two repeaters and nobody's fault, observedUnmatchedPackets is a missing entry in this instance's own configuration and the operator can act on it. It is not evidence for or against declaredWildcard — unmatched traffic is scoped, so it never affects wildcardContradiction, which counts only plain unscoped floods. Part of this count is explained: packets counted in regionEvidence are attributable to a declared region after all. A client showing this as a caveat should subtract them and report only the remainder, which carries a sharper meaning — traffic this repeater forwards for a region it does not declare and this instance cannot name. Two rules on that subtraction: count only evidence for regions absent from notObserved (a region with a single hit was deliberately not accepted as evidence, so its packet is not explained either), and compare against observedUnmatchedSampled first.

  • observedUnmatchedSampled is how many of those packets verification could actually examine. Two caps sit between the count and the evidence: the per-repeater working set (512 packets) and the per-window sample (the 4096 most recent unnameable packets, which a deployment with few hashRegions entries will reach). regionEvidence can only ever count packets inside that sample, so when this field is smaller than observedUnmatchedPackets the difference between the count and the evidence is an upper bound on the unexplained traffic rather than a figure, and a client should say so. Equal values mean the subtraction is exact.

  • regionEvidence maps a declared region to how many of this repeater's own unmatched forwarded packets derive to it. The server tests each declared region this repeater has no named evidence for by deriving SHA256("#region")[:16] and HMAC-ing that repeater's own unmatched packets with it — the same computation the ingestor performs at ingest, with the candidate set narrowed to this repeater's declarations. A region reaching 2 corroborating packets is removed from notObserved: code1 is two bytes, so one match happens by chance with probability 1/65536, while two on the same region is (1/65536)². A region with exactly one hit therefore stays in notObserved and appears here with the value 1, so a client can explain why it is still shown as not observed. notObserved remains the single source of truth for whether a region was observed; this field says only how that was established. The object is always present and may be empty.

  • All scope names in declaredRegions / notObserved / undeclaredObserved[].scope are already normalised (no leading #) — the server does the #/no-# reconciliation described on the per-node endpoint so this response is directly comparable without a client-side normalisation step.

  • '*' is never present in declaredRegions, notObserved, or undeclaredObserved — see declaredWildcard and wildcardContradiction for its dedicated (non-scope) treatment, same rule as the per-node endpoint.

  • configState is a derived reading of declaredRegions/declaredWildcard together — the repeater's overall configuration shape, rather than a per-region detail:

    • "full" — named regions and '*' declared: fully configured, forwards both its declared regions and plain unscoped floods.
    • "no-scopes" — '*' only, no named regions.
    • "no-unscoped" — named regions declared, '*' absent: this repeater does not forward plain unscoped floods. This reading is exact — '*' absent from the export always means the wildcard denies flooding.
    • "no-flood" — neither named regions nor '*': the repeater answered, but nothing at all is flood-allowed, not even plain unscoped traffic. The "no unscoped forwarding" half of this is exact for the same reason as "no-unscoped"; the "no named regions" half carries the same caveat as "no-scopes" below.

    Caveat on "no-scopes" and "no-flood": the firmware exports the FLOOD-allowed set (region_map.exportNamesTo(..., REGION_DENY_FLOOD) in examples/simple_repeater/MyMesh.cpp), not "every region this repeater has configured". A repeater with regions defined but every one of them marked deny-flood exports exactly the same list as a repeater with no region tree at all — the two are indistinguishable from this data alone. In practice "no-scopes" is almost always "no scopes configured", but a client MUST NOT present it as proven absence of configuration; word it as "no region is flood-allowed" rather than "no regions exist".

  • wildcardContradiction is true when the repeater was observed forwarding unscoped (plain-FLOOD) traffic this window but its declared list omits '*' — it declares it will NOT forward those packets, and the traffic says otherwise.

  • Rows are sorted with the interesting cases first: most notObserved entries first, then wildcardContradiction, then most undeclaredObserved entries, then alphabetically by name. A repeater in full agreement (no notObserved, no wildcardContradiction, no undeclaredObserved) sorts to the bottom.

  • Cached 30 seconds per window, mirroring /api/scope-stats.

  • Blacklisted nodes and nodes matching an operator-configured hidden-name prefix are excluded, same as other multi-node endpoints.


GET /api/scope-stats

Scope-based packet statistics over a time window. Requires ingestor scope_name_v1 migration to have run.

Query Parameters

Param Type Default Description
window string 24h Time window: 1h, 24h, 7d

Response 200

{
  "window":    string,               // echoed window ("1h", "24h", or "7d")
  "summary": {
    "transportTotal": number,        // scoped + unscoped transport-route packets
    "scoped":         number,        // Code1 ≠ 0000 (named + unknown regions)
    "unscoped":       number,        // transport-route with Code1 = 0000
    "unknownScope":   number         // scoped but no configured region matched (subset of scoped)
  },
  "byRegion": [
    { "name": string, "count": number }  // region name and packet count
  ],
  "timeSeries": [
    { "t": string (ISO), "scoped": number, "unscoped": number }  // bucket timestamps and counts
  ]
}

Notes:

  • transportTotal = scoped + unscoped (only route_type 0 or 3 packets)
  • scoped = packets with Code1 ≠ 0000
  • unscoped = transport-route packets with Code1 = 0000
  • unknownScope = scoped packets that did not match any configured region name
  • Time-series bucket size depends on window:
    • 1h window → 5-minute buckets
    • 24h window → 1-hour buckets
    • 7d window → 6-hour buckets
  • Cached 30 seconds

Note: On deployments with pre-existing data, unscoped will be inflated until the async startup backfill completes, because transport-route rows inserted before the scope_name_v1 migration ran have scope_name = NULL and are indistinguishable from Code1=0000 rows. The backfill goroutine populates them at startup but may take several minutes on large databases.

Response 400

{ "error": "window must be 1h, 24h, or 7d" }

Response 500 Internal Server Error

scope_name column does not exist (ingestor has not run migrations yet):

{ "error": "scope_name column not present — run ingestor to apply migrations" }

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: "CoreScope"
    "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/areas

Available area filters defined in config.json under areas. Used by the frontend to populate the area pill bar. Entries with an empty label (e.g. comment keys) are excluded.

Response 200

[
  {
    "key":   string,   // area key as defined in config (e.g. "bayarea")
    "label": string    // display name (e.g. "Bay Area")
  }
]

Returns [] when no areas are configured.

Note: Polygon coordinates are not included. Use /api/config/areas/polygons for the full geometry.


GET /api/config/areas/polygons

Full area definitions including polygon/bounding-box coordinates. Intended for map rendering tools (e.g. the area-map debug tool).

Response 200

[
  {
    "key":   string,
    "label": string,
    "polygon": [[number, number]] | undefined,   // [lat, lon] pairs (if polygon-style)
    "latMin":  number | undefined,               // bounding-box style
    "latMax":  number | undefined,
    "lonMin":  number | undefined,
    "lonMax":  number | undefined
  }
]

Returns [] when no areas are configured.


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

{
  "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)

Area Filter

The ?area=<key> query parameter is a display-side geographic filter that attributes data to a region based on the transmitting node's own GPS coordinates, as broadcast in its ADVERT packets. It is distinct from the observer-based ?region= filter.

Configuration

Areas are defined in config.json under the areas key:

{
  "areas": {
    "bayarea": {
      "label": "Bay Area",
      "polygon": [[37.9, -122.5], [37.9, -121.9], [37.3, -121.9], [37.3, -122.5]]
    },
    "sanjose": {
      "label": "San Jose",
      "latMin": 37.25, "latMax": 37.45,
      "lonMin": -122.05, "lonMax": -121.75
    }
  }
}

Each entry may use either a polygon (array of [lat, lon] pairs, minimum 3 points) or a bounding box (latMin/latMax/lonMin/lonMax). The polygon check uses standard ray-casting point-in-polygon.

Attribution rules

Packet type Area-attributable? Reason
ADVERT (4) Yes Carries public_key + transmitter GPS in payload
GRP_TXT (5), TXT_MSG (2), REQ (0), others No Sender is encrypted; origin cannot be determined

When ?area= is active, only ADVERT packets (and nodes derived from them) are included in filtered results. All other packet types are excluded. This is by design — non-ADVERT packets have encrypted senders and cannot be attributed to a geographic origin.

GPS staleness

Node GPS coordinates are read from the nodes table, which is updated on ADVERT ingest. A node that moves between areas will not be re-attributed until its next ADVERT (typically 12–24 hours for repeaters). The area node set is cached for 30 seconds server-side.

Endpoints supporting ?area=

Endpoint Area support
GET /api/nodes Filters node list by GPS in area
GET /api/analytics/rf Restricts RF stats to ADVERT packets from area nodes
GET /api/analytics/topology Counts only hops that resolve to nodes in the area
GET /api/analytics/channels Supported (not used by dashboard UI)
GET /api/analytics/distance Restricts distance paths to area-node transmitters
GET /api/analytics/hash-sizes Restricts hash analysis to area-node packets
GET /api/analytics/hash-collisions Restricts collision analysis to area-node packets
GET /api/nodes/clock-skew Restricts fleet clock skew list to nodes in area

Cross-antimeridian polygons

Polygons that span the 180° meridian (antimeridian) are not supported — ray-casting point-in-polygon breaks at the date line. Split such areas into two separate entries.