Files
meshcore-analyzer/docs/client-regions.md
T
efiten e01565737a feat(ingestor): store CoreDrive RX region answers, with position, clock and retention (#2047)
The Scope Audit page said a repeater's declared region list can come from CoreDrive RX while nothing the app sends ever reached it: the client-topic switch handled packets and rf only, so /regions was dropped without a log line, and node_declared_regions is read by region_keys.go and config.go but created by nothing in this tree. #2044 found that gap and proved it against a live instance.

This lands the implementation that has been carrying the feature in production on the ON8AR fork since 2026-09-06, the instance CoreDrive RX publishes to. Measured there: 1840 answers about 275 repeaters from 51 collectors, 2026-08-18 to 2026-09-19.

Beyond storing the answer it keeps three things the first version did not: position (lat, lon, pos_acc_m, filled on 1263 of 1840 rows, with acc_m dropped when the fix it qualifies was rejected), repeater_clock (filled on all 1840, so a wrong repeater clock cannot make an answer look newer than it is), and retention with per-collector history (pruneOldClientDeclaredRegionsAt bounds by age instead of keeping one row per target). On that dataset 138 of 275 repeaters have answers from more than one collector and 40 have collectors that disagree about the region list, which is the signal the Scope Audit exists to surface and which only survives while more than one answer does.

It gates on its own clientRegions block rather than riding on clientRxCoverage, so region answers can be accepted without GPS-tagged reception uploads, and AES block padding is trimmed from region names on ingest.

Taken from #2044 with the author credited as co-author: declaredRegionsTablePresent() and its test, a real bug this version lacked (supervisord starts both processes together, so a server that probes first ignores every answer until its next restart), and the docs/client-rx-coverage.md section.

Ported by cherry-picking the fork's nine commits rather than retyping, so this is the code that has been running. CI run 35434151026 is green: server ok 80.177s, ingestor ok 99.413s, race detector ok 112.406s, no --- FAIL lines.

Merged by the interim maintainer without a second human reviewer: CI and the production figures above are the independent checks.
2026-09-19 11:44:47 +02:00

8.0 KiB

Client Region-Discovery Answers

A companion mobile app asks each repeater it comes within direct RF range of which regions that repeater declares flood-allowed (ANON_REQ_TYPE_REGIONS), and relays the answer to CoreScope on a dedicated topic. CoreScope already shows what each repeater is observed forwarding (transported_scopes, derived from transport-route packets actually seen); this feature adds the declared side, which is what makes a comparison between "claims" and "does" possible.

Enabling region-discovery answers (operators)

Off by default. To turn it on:

  1. In CoreScope's config.json, set "clientRegions": { "enabled": true } and restart the ingestor. Independent of clientRxCoverage, clientRxObservations and clientRfSamples — a deployment can run any combination.
  2. Required: an ACL-capable broker, same as the other client streams. Bind meshcore/client/{PUBLIC_KEY}/regions so each companion may publish only under its own pubkey. The ingestor already subscribes under meshcore/#, so no separate subscription is needed.
  3. Optionally set retention.clientRegionsDays to bound the table.

MQTT topic & payload

Topic: meshcore/client/{PUBLIC_KEY}/regions — {PUBLIC_KEY} is the reporting companion's pubkey.

{
  "type": "REGIONS",
  "timestamp": "2026-08-18T10:00:00.000Z",
  "target": "bb11223344556677889900aabbccddeeff00112233445566778899aabbccddaa",
  "regions": ["be", "be-vlg"],
  "truncated": false,
  "repeater_clock": 1755504000,
  "gps": { "lat": 51.2, "lon": 4.4, "acc_m": 8.0 }
}
  • target is the repeater that was asked, as a lowercase-hex pubkey. It is payload data, not ACL-bound identity, so it is validated before being trusted — but validated strictly: exactly 64 hex characters, a full pubkey, not the looser 2-64-character rule used for the topic pubkey. The app cannot produce anything else — its request builder throws unless the pubkey it sends is exactly 64 hex characters, so it can only ever ask about (and report back) a full pubkey. A shorter target therefore indicates a malformed or forged payload, not a legitimate shorthand worth keeping: a wrongly-accepted short value would still insert a row, but CurrentDeclaredRegions matches on the exact target string, so that row would never surface for the real node's queries — it would just accumulate silently as junk that looks like data. A missing, malformed, or wrong-length target drops the whole message.
  • regions is the CSV-decoded list the repeater returned. An empty list is a valid, deliberate answer — the repeater declared nothing flood-allowed — and is stored as a row with an empty regions_csv, not dropped or treated as absence. A missing or malformed regions field (not an array, or containing a non-string entry) is a different thing entirely — a malformed payload, not an answer — and the whole message is dropped and logged rather than half-stored.
  • truncated is a hint the app forwards as reported, not something the ingestor re-derives: the firmware's exportNamesTo skips names that don't fit the reply's CSV budget and keeps going, so an overflowing answer has holes rather than a truncated prefix, with no marker on the wire that lets either side detect it after the fact. The app flags truncated heuristically (close to its budget ceiling); the ingestor stores that flag verbatim.
  • gps is optional, unlike the client coverage and RF-sample streams. A declared-regions answer is meaningful on its own — it says what a repeater claims to forward, regardless of where the companion was standing when it asked. A missing or out-of-range fix stores the row with lat/lon as SQL NULL rather than dropping it (contrast with handleClientPacket, which drops a position-less message outright because a coverage point without a position is not coverage at all). gps.acc_m is stored as pos_acc_m, the same column name client_rf_samples uses for this shape; a missing acc_m stores NULL, not 0.
  • repeater_clock is the repeater's own RTC reading at the moment it answered — the only signal on this topic that would reveal a repeater with a broken clock. Stored verbatim in the repeater_clock column via the same optInt64 helper handleClientRfSample uses for this shape: a missing field stores NULL, never 0 — absent is not zero, the same rule enforced for recv_errors on the /rf topic.
  • Subscription: the ingestor's default subscription (meshcore/#) already covers this topic. Sources configured with an explicit topic list must add meshcore/client/+/regions.

Silence carries no meaning

There is no "did not answer" message on this topic. The repeater only replies to a ANON_REQ_TYPE_REGIONS request delivered over a DIRECT route — a companion that sent the request but never received a reply (out of range, rate-limited, busy, or old firmware that doesn't implement the request) has nothing to publish, and publishes nothing. That means:

  • A row exists ⇒ the repeater answered, and the row is that answer (however it reads — an empty list included).
  • No row for a target ⇒ either it has never been asked, or it was asked and never answered. The absence itself is not evidence of anything about what the repeater declares — it must never be read as "declares nothing," which is what an empty-list row means.

Trust

Identity = the reporting companion's pubkey, taken from the {PUBLIC_KEY} topic segment — never from a payload-supplied field, which would defeat the ACL trust model. The ingestor rejects any topic pubkey that is not lowercase hex before writing (the same clientPubkeyRe used across every client sub-topic), and the observer blacklist is enforced before any client-topic write, so a blacklisted operator cannot contribute region-discovery answers any more than it can contribute coverage or RF samples.

target, unlike the topic pubkey, is not ACL-enforced — anyone publishing on their own meshcore/client/{own_pubkey}/regions topic could in principle claim any target. It is validated only as a well-formed pubkey, not verified against the actual RF exchange; treat node_declared_regions as one companion's report of what one repeater told it, not as ground truth independent of the reporting companion's trustworthiness.

Storage — node_declared_regions (ingestor-owned)

node_declared_regions(
  id, target, rx_pubkey, observed_at, ingested_at, regions_csv, truncated, lat, lon,
  pos_acc_m, repeater_clock,
  UNIQUE(target, rx_pubkey, observed_at))   -- idempotent re-ingest; different reporters both persist

Every answer is stored as an observation, never as replacing state — this table accumulates a history of answers rather than holding one row per target. observed_at is stored at millisecond precision (the rxTimeMillisLayout format shared with client_rx_observations.rx_at and client_rf_samples.sampled_at), not the second-resolution RFC3339 used by client_receptions.rx_at. This matters for the same reason as those tables: a future retention prune compares its cutoff lexicographically against these stored strings, and a second-resolution cutoff compared against millisecond-resolution values would delete rows inside the retention window (. sorts before Z).

Store.CurrentDeclaredRegions(target) returns the most recent answer for a target, ordered by observed_at (when the repeater actually answered), not ingested_at (when the row arrived at the ingestor) — a drive buffered offline and uploaded days later must not overwrite a fresher reading with a stale one just because it arrived later.

Retention: retention.clientRegionsDays bounds the table by observed_at; 0 disables it (Task 6).

Configurable values (future customizer)

retention.clientRegionsDays is the only tunable so far; no map rendering or comparison UI is built on this table yet in this task — that is future work once both the declared side (here) and the observed side (transported_scopes) can be joined and rendered together.