Files
meshcore-analyzer/docs/api-spec.md
T
efiten 0605b1703a feat(scope-audit): count and surface the traffic this instance cannot name (#1987)
Follow-up to #1986, and the second half of the same problem.

`ScopeAuditForwarding` drops rows whose `scope_name` is the empty string
with a bare `continue`. That empty string is the ingestor's
"transport-scoped, but no configured region key matched `code1`" state
(`scopeNameForDB`), so those packets name no region and can never
satisfy a declared one. The consequence is on the page: **a repeater
forwarding a region this instance holds no `hashRegions` key for is
reported exactly like a repeater forwarding nothing at all.** The audit
presents a gap in the reader's own configuration as a finding about
someone else's hardware.

This counts them per target, exposes the count as
`observedUnmatchedPackets`, and renders it as a caveat chip beside the
scope chips.

## Why it is not a rare edge

Measured on a live instance before this landed: of 613 `notObserved`
entries across 205 repeaters, **260 named a region that never appeared
under any name in the whole 7-day window**. Two of them (`behss`,
`fm-112`) were hash-verified as genuinely forwarded traffic the instance
simply could not name: packet `0a065d41d51f1f77` decodes to
`code1=9209`, which is exactly the code `#fm-112` derives over that
packet's own payload.

That instance had 16 region keys configured against 124 distinct region
names its repeaters declare. A stock install has fewer.

## What the counter is not

It is deliberately **not** folded into `unscopedPackets`. The two are
opposites:

| | meaning | what governs it |
|---|---|---|
| `unscopedPackets` | the packet carried no scope at all (`scope_name`
SQL NULL) | the `*` wildcard |
| `observedUnmatchedPackets` | the packet IS scoped, this instance holds
no key for that region | nothing the repeater declares |

For the same reason the new count never feeds `wildcardContradiction`,
which counts only plain unscoped floods. `scopeNameForDB` in the
ingestor is the source of truth for that three-state encoding, and the
comments point there rather than restating it.

It is also distinct from `ambiguousHops`, and the distinction is the
point of the chip: that one is a pubkey-prefix collision between two
repeaters and is nobody's fault, this one is a missing entry in the
reader's own configuration and they can act on it. Saying which is which
is what stops someone investigating an innocent repeater.

## Frontend

The chip reuses the muted dashed treatment of `.sa-chip-ambiguous` on
purpose: both are caveats on the row's finding rather than findings
themselves, and neither may compete visually with the red/green scope
chips beside them.

It renders nothing for a non-numeric count. The value is
server-supplied, and a truthiness check would put the literal string
`NaN forwarded packets` on the page if that ever stopped holding.

## Docs

`docs/api-spec.md` had **no entry for `GET /api/scope-audit` at all**,
so this adds one: query parameter, full response shape, and the notes a
client needs (the three traps the per-node endpoint documents apply here
identically, `*` is never a scope, and "never asked" is not "declared
nothing"). The new field is documented there rather than in isolation.

## Tests

- the counter on a last-hop and on a mid-path hop
- an unmatched packet enters neither `agg.scopes` nor `unscopedPackets`,
which is the confusion this field exists to prevent
- the field on the API row
- six frontend cases: zero renders nothing, a missing field renders
nothing (older server), the chip carries its count and class, singular
and plural are both grammatical, the title names the cause and the fix,
and a non-numeric count renders nothing rather than `NaN`

`cd cmd/server && go test ./...` passes, frontend 712 assertions pass,
`go vet` and `gofmt -l` clean.

Rule 0: the counter is one increment on a branch that already existed as
a `continue`, inside a loop this PR does not change. No new query, no
new pass over the data.
2026-09-09 17:05:29 +02:00

69 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
    }
  ]
}

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.

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