Files
meshcore-analyzer/docs/api-spec.md
T
efitenandClaude Opus 5 d1b615fc0d fix(node-health): list only observers that heard the node on air (#2057)
Closes #2056.

## What changes

The node detail "Heard By" card now lists only observers that received
the node's **own transmission off the air**, and reports the rest as a
count.

```
HEARD BY — DIRECT (8 OBSERVERS)
OBSERVER                REGION  PACKETS  AVG SNR   AVG RSSI
BE-DUF-SiSCD-01         —        16276    7.9 dB   -108 dBm
BE-BRU-Moris  repeater  —        13775   -5.6 dB   -122 dBm
...
Seen via relay by 29 observers. Those observers heard a repeater that
forwarded this node's traffic, not this node.
```

and for a node nothing hears:

```
HEARD BY — DIRECT (0 OBSERVERS)
No observer is within radio range of this node.
Seen via relay by 2 observers. …
```

## The rule, and where it comes from

Read out of the firmware rather than assumed:

| | |
|---|---|
| `Packet.h:83` | `setPathHashSizeAndCount(sz,n) { path_len =
((sz-1)<<6) \| (n&63); }` — hash size rides in the packet's `path_len`
byte |
| `Mesh.cpp:649,678` | only `sendFlood()` sets it, so the **originator**
decides; `CommonCLI.h:69` defaults `path_hash_mode = 0`, i.e. one byte |
| `Mesh.cpp:349` | a forwarding repeater appends its hash with the
packet's size — it cannot upgrade a packet, and the **last hop is who
was heard** |
| `Mesh.cpp:89,103` | on a direct route a forwarder matches the head of
the path and calls `removeSelfFromPath` before retransmitting, so the
path is the **remaining** route and the transmitter is not in it |

So an observation credits exactly one node:

1. Route type must be `ROUTE_TYPE_FLOOD` or
`ROUTE_TYPE_TRANSPORT_FLOOD`. Direct routes never qualify (38% of
transmissions over 7 days).
2. Empty path → the originator, known only for ADVERTs.
3. Otherwise the last hop.
4. The hop must resolve to exactly one candidate. Same gate
`resolvePathForObsColdLoad` already applies: under-attribute rather than
guess. It drops 418,530 of 1,455,721 flood observations with a path over
7 days (28.8%), and it is what stops the wrong-band credits.

## Measured effect

| node | before | after |
|---|---|---|
| BE-BRU-Moris | 36 observers | 3 |
| BE-KRO-RP01 \| ON1KW | 40 | 3 |
| BE-BRE-ON8AR | 38 | 2 |
| NL-BXE-RP01 \| 433 | 35 | 0 |

Network-wide over 7 days, 234 of 1,860 nodes have at least one direct
observer (161 have exactly one, maximum 8). The direct list is therefore
empty for most nodes, with the relay count below it. That is the correct
reading: no observer is in radio range of them.

Independent corroboration on staging: for BE-WIL-3EIK-01 the eight
direct observers are exactly the top eight entries of its Neighbors
table by score and observation count.

## Perf justification

`GetNodeHealth` is fast today precisely because it never walks
observations — it uses one representative observation per transmission.
Direct-RF needs the per-observation path, and that cannot be a
per-request walk: the reference store holds **232,928 transmissions /
2,887,861 observations**, one node's `byNode` slice alone holds **55,458
transmissions / 1,450,544 observations**, and
`/api/nodes/bulk-health?limit=200` would multiply that.

So the aggregate is rebuilt by a background recomputer on the existing
`newAnalyticsRecomputer` pattern, published into an `atomic.Value`.
Reads are `O(direct observers)`, which is **cheaper than before** — the
old code built per-observer sums over every transmission in `byNode` on
every request.

Proof, `BenchmarkBuildDirectHeardIndex`:

```
BenchmarkBuildDirectHeardIndex-12    1    63067900 ns/op
```

3,000,000 observations (60,000 transmissions × 50 observations, 8-hop
paths, 64 candidate repeaters) in **63 ms**, once per recompute
interval.

Per observation the walk does one route-type check, one backward scan of
`PathJSON` for the last quoted token (no allocation, no
`json.Unmarshal`), one prefix-map lookup and one counter update.

Rebuilding wholesale also means eviction needs no bookkeeping: a pass
simply does not see evicted transmissions. The alternative — a field on
`StoreObs` updated incrementally — would have needed the call at five
construction sites (`store.go:942,1264,2854,3179`,
`chunked_load.go:609`), which is the duplication that caused #1558, plus
matching decrements at eviction.

## API

Both `GetNodeHealth` and `GetBulkHealth` carried a near-identical copy
of the observer loop; they now share one builder.

- `observers` — direct-RF only. Same field names, so no client
migration. Rows are a named `HealthObserverRow` instead of
`map[string]interface{}` (one fewer occurrence in a touched file, per
the AGENTS.md ratchet).
- `relayObserverCount` — new integer, observers that saw traffic through
the node without hearing it. `stats.totalPackets` and `stats.avgHops`
still count relayed traffic, so without this number the card would
contradict the figures printed beside it.

`docs/api-spec.md` is updated for both endpoints. It also documented an
`iata` field on these rows that the endpoint has never emitted; removed.

## Tests

- `cmd/server/direct_heard_test.go` — table test over the rule: flood
with empty path and known originator, flood whose last hop is the node,
flood whose last hop is another node, direct and transport-direct routes
(never credit), ambiguous last-hop prefix, listener-only candidate,
1-byte and 2-byte hop sizes; plus aggregation and row-building.
- `cmd/server/node_health_direct_rf_test.go` — end-to-end through the
handler: an observer that only saw relayed traffic must not appear in
`observers` but must be counted in `relayObserverCount`. Plus the
benchmark.
- `tests/unit/test-direct-rf-heard-by.js` — slices the card template out
of `public/nodes.js` and evaluates it, so it tests the shipped markup
rather than a copy: heading, empty state, relay line, singular/plural,
signal columns, listener/repeater badge tri-state.
- `cmd/server/node_health_can_relay_case_1290_test.go` — updated to seed
a genuinely direct reception, since a relay-only observer no longer
carries a badge.
- `cmd/server/analytics_recompute_after_load_test.go` — recomputer count
10 → 11.

Verified locally: `cmd/server` suite green, `sh test-all.sh` green (180
suites), `tests/e2e/test-e2e-playwright.js` 131/134 passed with 3
skipped and 0 failures against the seeded fixture, plus
`test-issue-1147-section-order-e2e.js`,
`test-issue-1151-orphan-separators-e2e.js` and
`test-issue-1281-location-row-e2e.js`, which all assert on this card.
`gofmt` clean, `vet` clean across all modules.

Browser-validated on staging: both the full detail page and the side
pane, on a node with 8 direct observers and on the 433 MHz node with
none. No console errors.

## What this does not do

`prefixMap.resolveWithContext` still guesses on ambiguous hops, so
paths, neighbor edges and analytics keep their current attribution.
Making it abstain is a much larger change and needs its own issue.

The "Regions" line and Region column on this card read `o.iata`, which
this endpoint has never emitted, so both have always been dead. Left as
found rather than widened into this change.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 07:55:37 +02:00

84 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.
      "scope_config_state": string | undefined, // Repeater/room only. How this node's region config reads: "full" | "no-unscoped" | "no-scopes" | "no-flood" from its own declared-regions answer, "observed" when it never answered but has been seen forwarding scoped traffic, "none" when it never answered and nothing scoped was observed. Absent for other roles and when the declared-regions lookup failed.
      "declared_regions": [string] | undefined, // Repeater/room only. Named regions from this node's newest declared-regions answer, spelled like GET /api/scope-audit `declaredRegions` (leading "#" stripped, "*" excluded). [] = answered with no named region. Absent = never answered, other roles, no declared-regions source, or the declared-regions lookup failed.
      "declared_regions_truncated": true | undefined // Repeater/room only. Present, and true, only when the answer behind declared_regions was flagged as truncated: the list is partial. Same flag as GET /api/scope-audit `truncated`.
    }
  ],
  "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.
  • declared_regions is what the map's region-scope filter reads (#1862), together with transported_scopes. It is the same list the Scope Audit shows as declaredRegions, split and normalised by the same function, so the audit's region search and the map list a repeater under the same names. Its presence follows scope_config_state: set whenever that field is one of the four declared states. An absent field or a region missing from the list is not evidence that the repeater lacks that region.
  • declared_regions_truncated is set only when the newest declared answer was flagged as cut off (node_declared_regions.truncated), and the map popup then marks the declared list as truncated, as the Scope Audit does. It is never false: the nodes.configured_scope source does not record whether its size cap fired, so an absent field does not mean the list is complete.
  • scope_config_state carries the same four declared states as GET /api/scope-audit and is computed the same way, from the newest declared-regions answer merged across collectors. The audit lists only repeaters that have answered; this field also classifies the ones that have not, which is what the map colours by. "none" means the answer is missing, not that the node is misconfigured: firmware drops scoped floods for regions it holds no key for, so a repeater with no region config and one nobody sends scoped traffic past are indistinguishable here. The four declared states are computed from stored data and do not depend on a window; "observed" and "none" are separated by transported_scopes, which the in-memory packet store accumulates over its retention window, so a node can move between them after a restart. The field is absent entirely when this database carries no declared-regions source at all, since "nobody has answered" is then a claim the schema cannot support.

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
    },
    // Direct-RF only, same shape and rule as GET /api/nodes/:pubkey/health.
    "observers": [
      {
        "observer_id":   string,
        "observer_name": string | null,
        "avgSnr":        number | null,
        "avgRssi":       number | null,
        "packetCount":   number,
        "can_relay":     boolean | null
      }
    ],
    "relayObserverCount": 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 that received this node's OWN transmission off the air:
  // a flood packet whose last path hop resolves unambiguously to this
  // node, or a flood ADVERT it originated that arrived with an empty
  // path. Only here do avgSnr/avgRssi describe this node's signal.
  // Direct routes never qualify: their path is the remaining route,
  // not the travelled one.
  "observers": [
    {
      "observer_id":   string,
      "observer_name": string | null,
      "packetCount":   number,
      "avgSnr":        number | null,
      "avgRssi":       number | null,
      "can_relay":     boolean | null  // null = observer never reported a repeat field
    }
  ],
  // Observers that saw traffic through this node without hearing it.
  // The stats below count that relayed traffic, so this keeps the two
  // consistent. Most nodes have no observer in radio range at all.
  "relayObserverCount": number,
  "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/hop_analytics

Hop count at this node for every flood packet it forwarded, to help choose flood.max, flood.max.unscoped and flood.max.advert. A repeater checks those limits against the number of hashes already in the path, then appends its own hash, so the hop count is the node's zero-based index in the observed path (firmware src/helpers/RoutingPolicy.h, src/Mesh.cpp routeRecvPacket). This is not the hopDistribution of /analytics, which is the path length at the observer.

  • One entry per packet hash. Values are raw so the client can filter and bin.
  • Only floods (route types 0 and 1). DIRECT packets carry the remaining route, not a hop count, and are left out. Packets the node originated are left out.
  • Every observation of every flood packet in the window is read, not only the packet's longest path, so a relay on a shorter branch of the flood counts too.
  • A packet is attributed when the node's path prefix sits at exactly one index across its observations, and either no other relay-capable node shares that prefix, or the hop resolves to the node under the ingestor's strict rule (every earlier hop identified without a tiebreak, and exactly one candidate is a neighbor_edges neighbor of the previous hop, or of the originator for an advert) in at least one observation and to another node in none. The server's resolved-path pick (affinity, GPS distance, advert count) is not used, so the result is the same before and after a restart. Everything else with the node's prefix is counted in ambiguous and left out; in practice that is most packets with a colliding 1-byte path hash.
  • Size: for a busy repeater on a 1,669-node mesh over 7 days (2026-09-13) the response held 23,068 entries, 2.3 MB of JSON, 375 KB gzipped. hash and timestamp are 61% of the raw and 91% of the gzipped bytes; they stay so a client can join entries to packets and bin by time (issue #1812).

Query Parameters

Param Type Default Description
days number 7 Lookback window (1-365)

Response 200

{
  "timeRange": { "from": string (ISO), "to": string (ISO), "days": number },
  "packets": [
    {
      "hash":      string,
      "timestamp": string (ISO),      // first seen
      "hops":      number,            // 0 = heard straight from the originator
      "tags":      [string]           // "flood", then "scoped" or "unscoped", then "advert" if an ADVERT
    }
  ],
  "ambiguous": number                 // prefix matched, hop position not attributable to this node
}

Filters that match the firmware limits: flood.max uses all entries, flood.max.unscoped the entries tagged unscoped, flood.max.advert the entries tagged advert.

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,
      "scope_name":       string | null     // region scope, see below
    }
  ],
  "total": number                           // total deduplicated messages
}

scope_name is the transmission's region scope (transmissions.scope_name), the same field /api/packets returns:

Value Meaning
null No transport code: the message was not region-scoped. Also null when the database has no scope_name column yet (ingestor migration not run).
"" Transport-scoped, but the ingestor could not match it to a single region: no region key matched, or several matched with no single operator-configured key among them.
"#name" The matched region name.

The same field is on the WebSocket packet broadcast, both top-level and inside packet.


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/retransmissions

Retransmission pressure over time (#1699): a collision-pressure proxy, not a measured collision rate.

For each flood event of a flood-routed packet (route_type 0 or 1, TRACE excluded) the server takes the union of the paths of all its observations and counts the distinct repeaters in it: paths [A], [A,B,C] and [A,D] give 4. Direct routes are excluded because their path is the route still to travel, not the forwarders; zero-hop sends are direct routes. A flood event heard only with an empty path counts as 0 repeaters.

A transmission is one packet hash, and the same bytes can flood again later: those observations are stored on the same transmission. Its observations are therefore sorted by time and split into flood events wherever two consecutive observations are more than 5 minutes apart. Each event is counted on its own and bucketed by its first observation. A firmware node holds a flood for at most 32 s before forwarding it, plus a random retransmit delay, so a flood that is still spreading is not split. Every count in the response (packets, one_byte_packets, no_repeater_packets) counts flood events. Observations are stored once per observer and path per transmission, so a later event holds only the observer and path pairs not already stored for that hash, and its count is a lower bound.

Events that start before the store's retention floor (now minus retentionHours) are left out for every request shape, including explicit window, from and to: the store keeps observations older than that only for hashes heard again recently, so they do not represent that period.

Hop prefixes are not resolved to nodes. A prefix counts once per flood event, whether it repeats across observations or inside one path. A 2- or 3-byte prefix that repeats inside one path is one node forwarding the flood again after its duplicate filter (a cyclic buffer of 160 hashes) dropped the hash. A repeated 1-byte prefix can also be two nodes; counting it once keeps the value a lower bound, as does merging repeaters that share a prefix across observations. Only repeaters that some observer heard are counted, so the value also follows observer coverage.

The default shape (no region, no window, bucket=1h) is served from the analytics recomputer; other shapes use the TTL cache, and concurrent requests for the same uncached shape share one computation. During startup the default shape returns 503 with Retry-After until the recomputer completes a pass after the hot startup window has loaded, or for at most 60 s after the recomputer started, whichever comes first. The history beyond the hot window keeps loading in the background after that, so until the first recompute pass after that load finishes the default shape can cover less than the retention window. ?area= is not supported: the area filter works on resolved node public keys and this metric does not resolve prefixes.

Query Parameters

Param Type Default Description
region string none Comma-separated IATA codes; only observations from the region's observers feed the union, events none of them heard are skipped. Events are split before this filter. A region with no known observers is not filtered and returns network-wide data, as /api/analytics/rf does
window string none 1h, 24h, 3d, 7d or 30d (relative to now)
from, to string (ISO) none Absolute window bounds, take precedence over window
bucket string 1h 5m, 15m, 1h, 6h or 1d; other values fall back to 1h

Response 200

{
  "bucket_seconds": number,
  "window":         string,          // window label, "" for all data
  "region":         string,
  "summary": {
    "packets":             number,   // flood events that started in the window
    "avg_repeaters":       number,   // mean distinct repeaters per event
    "observers":           number,   // distinct observers that heard them
    "one_byte_packets":    number,   // events on 1-byte hop hashes (most ambiguous)
    "no_repeater_packets": number    // events heard with an empty path only
  },
  "buckets": [                       // ascending, empty buckets omitted
    {
      "start":         string (ISO), // bucket start, UTC
      "packets":       number,       // flood events that started in the bucket
      "repeater_sum":  number,
      "avg_repeaters": number,
      "observers":     number
    }
  ]
}

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
  ],
  "advertsByRole": [
    {
      "role":         string,        // sender's nodes.role, or "unknown" (see notes below)
      "unscoped":     number,        // flood adverts with no scope (scope_name NULL)
      "unknownScope": number,        // scoped, but no region name could be assigned (scope_name "")
      "named":        number         // scoped with a named region
    }
  ]
}

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
  • advertsByRole (#1979) counts ADVERT packets on flood routes only (TRANSPORT_FLOOD 0, FLOOD 1) in the window, grouped by the sender's current nodes.role. Zero-hop adverts (DIRECT/TRANSPORT_DIRECT) are excluded. Ordered by total adverts descending, then role. It reports what was sent per role, not why. role is "unknown" when the advert row has no from_pubkey (legacy rows the ingestor's #1143 from_pubkey backfill has not reached yet), when the sender has no row in nodes (including a sender the ingestor's node retention moved to inactive_nodes, which within the 7d window only happens with retention.nodeDays below 7), or when its nodes.role is empty.
  • 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.